1use crate::formats::{CSVFormat, TSVFormat};
11use crate::{ConflictStrategy, merge_resources};
12use crate::{
13 error::Error,
14 formats::*,
15 traits::Parser,
16 types::{Entry, Resource},
17};
18use std::path::Path;
19
20#[derive(Debug, Clone)]
23pub struct Codec {
24 pub resources: Vec<Resource>,
26}
27
28impl Default for Codec {
29 fn default() -> Self {
30 Codec::new()
31 }
32}
33
34impl Codec {
35 pub fn new() -> Self {
41 Codec {
42 resources: Vec::new(),
43 }
44 }
45
46 pub fn builder() -> crate::builder::CodecBuilder {
67 crate::builder::CodecBuilder::new()
68 }
69
70 pub fn iter(&self) -> std::slice::Iter<'_, Resource> {
72 self.resources.iter()
73 }
74
75 pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Resource> {
77 self.resources.iter_mut()
78 }
79
80 pub fn get_by_language(&self, lang: &str) -> Option<&Resource> {
82 self.resources
83 .iter()
84 .find(|res| res.metadata.language == lang)
85 }
86
87 pub fn get_mut_by_language(&mut self, lang: &str) -> Option<&mut Resource> {
89 self.resources
90 .iter_mut()
91 .find(|res| res.metadata.language == lang)
92 }
93
94 pub fn add_resource(&mut self, resource: Resource) {
96 self.resources.push(resource);
97 }
98
99 pub fn extend_from(&mut self, mut other: Codec) {
101 self.resources.append(&mut other.resources);
102 }
103
104 pub fn from_codecs<I>(codecs: I) -> Self
106 where
107 I: IntoIterator<Item = Codec>,
108 {
109 let mut combined = Codec::new();
110 for mut c in codecs {
111 combined.resources.append(&mut c.resources);
112 }
113 combined
114 }
115
116 pub fn merge_codecs<I>(codecs: I, strategy: &ConflictStrategy) -> Self
120 where
121 I: IntoIterator<Item = Codec>,
122 {
123 let mut combined = Codec::from_codecs(codecs);
124 let _ = combined.merge_resources(strategy);
125 combined
126 }
127
128 pub fn find_entries(&self, key: &str) -> Vec<(&Resource, &Entry)> {
155 let mut results = Vec::new();
156 for resource in &self.resources {
157 for entry in &resource.entries {
158 if entry.id == key {
159 results.push((resource, entry));
160 }
161 }
162 }
163 results
164 }
165
166 pub fn find_entry(&self, key: &str, language: &str) -> Option<&Entry> {
190 self.get_by_language(language)?
191 .entries
192 .iter()
193 .find(|entry| entry.id == key)
194 }
195
196 pub fn find_entry_mut(&mut self, key: &str, language: &str) -> Option<&mut Entry> {
222 self.get_mut_by_language(language)?
223 .entries
224 .iter_mut()
225 .find(|entry| entry.id == key)
226 }
227
228 pub fn update_translation(
259 &mut self,
260 key: &str,
261 language: &str,
262 translation: crate::types::Translation,
263 status: Option<crate::types::EntryStatus>,
264 ) -> Result<(), Error> {
265 if let Some(entry) = self.find_entry_mut(key, language) {
266 entry.value = translation;
267 if let Some(new_status) = status {
268 entry.status = new_status;
269 }
270 Ok(())
271 } else {
272 Err(Error::InvalidResource(format!(
273 "Entry '{}' not found in language '{}'",
274 key, language
275 )))
276 }
277 }
278
279 pub fn add_entry(
312 &mut self,
313 key: &str,
314 language: &str,
315 translation: crate::types::Translation,
316 comment: Option<String>,
317 status: Option<crate::types::EntryStatus>,
318 ) -> Result<(), Error> {
319 let resource = if let Some(resource) = self.get_mut_by_language(language) {
321 resource
322 } else {
323 let new_resource = crate::types::Resource {
325 metadata: crate::types::Metadata {
326 language: language.to_string(),
327 domain: "".to_string(),
328 custom: std::collections::HashMap::new(),
329 },
330 entries: Vec::new(),
331 };
332 self.add_resource(new_resource);
333 self.get_mut_by_language(language).unwrap()
334 };
335
336 let entry = crate::types::Entry {
337 id: key.to_string(),
338 value: translation,
339 comment,
340 status: status.unwrap_or(crate::types::EntryStatus::New),
341 custom: std::collections::HashMap::new(),
342 };
343 resource.add_entry(entry);
344 Ok(())
345 }
346
347 pub fn remove_entry(&mut self, key: &str, language: &str) -> Result<(), Error> {
372 if let Some(resource) = self.get_mut_by_language(language) {
373 let initial_len = resource.entries.len();
374 resource.entries.retain(|entry| entry.id != key);
375
376 if resource.entries.len() == initial_len {
377 Err(Error::InvalidResource(format!(
378 "Entry '{}' not found in language '{}'",
379 key, language
380 )))
381 } else {
382 Ok(())
383 }
384 } else {
385 Err(Error::InvalidResource(format!(
386 "Language '{}' not found",
387 language
388 )))
389 }
390 }
391
392 pub fn copy_entry(
421 &mut self,
422 key: &str,
423 from_language: &str,
424 to_language: &str,
425 update_status: bool,
426 ) -> Result<(), Error> {
427 let source_entry = self.find_entry(key, from_language).ok_or_else(|| {
428 Error::InvalidResource(format!(
429 "Entry '{}' not found in source language '{}'",
430 key, from_language
431 ))
432 })?;
433
434 let mut new_entry = source_entry.clone();
435 if update_status {
436 new_entry.status = crate::types::EntryStatus::New;
437 }
438
439 let target_resource = if let Some(resource) = self.get_mut_by_language(to_language) {
441 resource
442 } else {
443 let new_resource = crate::types::Resource {
445 metadata: crate::types::Metadata {
446 language: to_language.to_string(),
447 domain: "".to_string(),
448 custom: std::collections::HashMap::new(),
449 },
450 entries: Vec::new(),
451 };
452 self.add_resource(new_resource);
453 self.get_mut_by_language(to_language).unwrap()
454 };
455
456 target_resource.entries.retain(|entry| entry.id != key);
458 target_resource.add_entry(new_entry);
459 Ok(())
460 }
461
462 pub fn languages(&self) -> impl Iterator<Item = &str> {
481 self.resources.iter().map(|r| r.metadata.language.as_str())
482 }
483
484 pub fn all_keys(&self) -> impl Iterator<Item = &str> {
503 use std::collections::HashSet;
504
505 let mut keys = HashSet::new();
506 for resource in &self.resources {
507 for entry in &resource.entries {
508 keys.insert(entry.id.as_str());
509 }
510 }
511 keys.into_iter()
512 }
513
514 pub fn has_entry(&self, key: &str, language: &str) -> bool {
538 self.find_entry(key, language).is_some()
539 }
540
541 pub fn entry_count(&self, language: &str) -> usize {
563 self.get_by_language(language)
564 .map(|r| r.entries.len())
565 .unwrap_or(0)
566 }
567
568 pub fn validate(&self) -> Result<(), Error> {
587 if self.resources.is_empty() {
589 return Err(Error::InvalidResource("No resources found".to_string()));
590 }
591
592 let mut languages = std::collections::HashSet::new();
594 for resource in &self.resources {
595 if !languages.insert(&resource.metadata.language) {
596 return Err(Error::InvalidResource(format!(
597 "Duplicate language found: {}",
598 resource.metadata.language
599 )));
600 }
601 }
602
603 for resource in &self.resources {
605 if resource.entries.is_empty() {
606 return Err(Error::InvalidResource(format!(
607 "Resource for language '{}' has no entries",
608 resource.metadata.language
609 )));
610 }
611 }
612
613 Ok(())
614 }
615
616 pub fn validate_plurals(&self) -> Result<(), Error> {
622 use crate::plural_rules::collect_resource_plural_issues;
623
624 let mut reports = Vec::new();
625 for res in &self.resources {
626 reports.extend(collect_resource_plural_issues(res));
627 }
628
629 if reports.is_empty() {
630 return Ok(());
631 }
632
633 let mut lines = Vec::new();
635 for r in reports {
636 let miss: Vec<String> = r.missing.iter().map(|k| format!("{:?}", k)).collect();
637 let have: Vec<String> = r.have.iter().map(|k| format!("{:?}", k)).collect();
638 lines.push(format!(
639 "lang='{}' key='{}': missing plural categories: [{}] (have: [{}])",
640 r.language,
641 r.key,
642 miss.join(", "),
643 have.join(", ")
644 ));
645 }
646 Err(Error::validation_error(lines.join("\n")))
647 }
648
649 pub fn collect_plural_issues(&self) -> Vec<crate::plural_rules::PluralValidationReport> {
651 use crate::plural_rules::collect_resource_plural_issues;
652 let mut reports = Vec::new();
653 for res in &self.resources {
654 reports.extend(collect_resource_plural_issues(res));
655 }
656 reports
657 }
658
659 pub fn autofix_fill_missing_from_other(&mut self) -> usize {
662 use crate::plural_rules::autofix_fill_missing_from_other_resource;
663 let mut total = 0usize;
664 for res in &mut self.resources {
665 total += autofix_fill_missing_from_other_resource(res);
666 }
667 total
668 }
669
670 pub fn clean_up_resources(&mut self) {
672 self.resources
673 .retain(|resource| !resource.entries.is_empty());
674 }
675
676 pub fn validate_placeholders(&self, strict: bool) -> Result<(), Error> {
700 use crate::placeholder::signature;
701 use crate::types::Translation;
702 use std::collections::HashMap;
703
704 let mut map: HashMap<String, HashMap<String, Vec<Vec<String>>>> = HashMap::new();
706
707 for res in &self.resources {
708 for entry in &res.entries {
709 let sigs: Vec<Vec<String>> = match &entry.value {
710 Translation::Singular(v) => vec![signature(v)],
711 Translation::Plural(p) => p.forms.values().map(|v| signature(v)).collect(),
712 };
713 map.entry(entry.id.clone())
714 .or_default()
715 .entry(res.metadata.language.clone())
716 .or_default()
717 .push(sigs.into_iter().flatten().collect());
718 }
719 }
720
721 let mut problems = Vec::new();
722
723 for (key, langs) in map {
724 let mut per_lang_sig: HashMap<String, Vec<String>> = HashMap::new();
726 for (lang, sig_lists) in langs {
727 if let Some(first) = sig_lists.first() {
728 if sig_lists.iter().any(|s| s != first) {
729 problems.push(format!(
730 "Key '{}' in '{}': inconsistent placeholders across forms: {:?}",
731 key, lang, sig_lists
732 ));
733 }
734 per_lang_sig.insert(lang, first.clone());
735 }
736 }
737
738 if let Some((base_lang, base_sig)) = per_lang_sig.iter().next() {
740 for (lang, sig) in &per_lang_sig {
741 if sig != base_sig {
742 problems.push(format!(
743 "Key '{}' mismatch: {} {:?} vs {} {:?}",
744 key, base_lang, base_sig, lang, sig
745 ));
746 }
747 }
748 }
749 }
750
751 if problems.is_empty() {
752 return Ok(());
753 }
754 if strict {
755 return Err(Error::validation_error(format!(
756 "Placeholder issues: {}",
757 problems.join(" | ")
758 )));
759 }
760 Ok(())
762 }
763
764 pub fn collect_placeholder_issues(&self) -> Vec<String> {
769 use crate::placeholder::signature;
770 use crate::types::Translation;
771 use std::collections::HashMap;
772
773 let mut map: HashMap<String, HashMap<String, Vec<Vec<String>>>> = HashMap::new();
774 for res in &self.resources {
775 for entry in &res.entries {
776 let sigs: Vec<Vec<String>> = match &entry.value {
777 Translation::Singular(v) => vec![signature(v)],
778 Translation::Plural(p) => p.forms.values().map(|v| signature(v)).collect(),
779 };
780 map.entry(entry.id.clone())
781 .or_default()
782 .entry(res.metadata.language.clone())
783 .or_default()
784 .push(sigs.into_iter().flatten().collect());
785 }
786 }
787
788 let mut problems = Vec::new();
789 for (key, langs) in map {
790 let mut per_lang_sig: HashMap<String, Vec<String>> = HashMap::new();
791 for (lang, sig_lists) in langs {
792 if let Some(first) = sig_lists.first() {
793 if sig_lists.iter().any(|s| s != first) {
794 problems.push(format!(
795 "Key '{}' in '{}': inconsistent placeholders across forms: {:?}",
796 key, lang, sig_lists
797 ));
798 }
799 per_lang_sig.insert(lang, first.clone());
800 }
801 }
802 if let Some((base_lang, base_sig)) = per_lang_sig.iter().next() {
803 for (lang, sig) in &per_lang_sig {
804 if sig != base_sig {
805 problems.push(format!(
806 "Key '{}' mismatch: {} {:?} vs {} {:?}",
807 key, base_lang, base_sig, lang, sig
808 ));
809 }
810 }
811 }
812 }
813 problems
814 }
815
816 pub fn normalize_placeholders_in_place(&mut self) {
832 use crate::placeholder::normalize_placeholders;
833 use crate::types::Translation;
834 for res in &mut self.resources {
835 for entry in &mut res.entries {
836 match &mut entry.value {
837 Translation::Singular(v) => {
838 let nv = normalize_placeholders(v);
839 *v = nv;
840 }
841 Translation::Plural(p) => {
842 for v in p.forms.values_mut() {
843 let nv = normalize_placeholders(v);
844 *v = nv;
845 }
846 }
847 }
848 }
849 }
850 }
851
852 pub fn merge_resources(&mut self, strategy: &ConflictStrategy) -> usize {
888 let mut grouped_resources: std::collections::HashMap<String, Vec<Resource>> =
890 std::collections::HashMap::new();
891 for resource in &self.resources {
892 grouped_resources
893 .entry(resource.metadata.language.clone())
894 .or_default()
895 .push(resource.clone());
896 }
897
898 let mut merge_count = 0;
899
900 for (_language, resources) in grouped_resources {
902 if resources.len() > 1 {
903 match merge_resources(&resources, strategy) {
904 Ok(merged) => {
905 self.resources.retain(|r| r.metadata.language != _language);
907 self.resources.push(merged);
908 merge_count += 1;
909 }
910 Err(e) => {
911 panic!("Unexpected error merging resources: {}", e);
915 }
916 }
917 }
918 }
919
920 merge_count
921 }
922
923 pub fn write_resource_to_file(resource: &Resource, output_path: &str) -> Result<(), Error> {
951 use crate::formats::{
952 AndroidStringsFormat, CSVFormat, StringsFormat, TSVFormat, XcstringsFormat,
953 };
954 use std::path::Path;
955
956 let format_type =
958 crate::converter::infer_format_from_extension(output_path).ok_or_else(|| {
959 Error::InvalidResource(format!(
960 "Cannot infer format from output path: {}",
961 output_path
962 ))
963 })?;
964
965 match format_type {
966 crate::formats::FormatType::AndroidStrings(_) => {
967 AndroidStringsFormat::from(resource.clone())
968 .write_to(Path::new(output_path))
969 .map_err(|e| {
970 Error::conversion_error(
971 format!("Error writing AndroidStrings output: {}", e),
972 None,
973 )
974 })
975 }
976 crate::formats::FormatType::Strings(_) => StringsFormat::try_from(resource.clone())
977 .and_then(|f| f.write_to(Path::new(output_path)))
978 .map_err(|e| {
979 Error::conversion_error(format!("Error writing Strings output: {}", e), None)
980 }),
981 crate::formats::FormatType::Xcstrings => {
982 XcstringsFormat::try_from(vec![resource.clone()])
983 .and_then(|f| f.write_to(Path::new(output_path)))
984 .map_err(|e| {
985 Error::conversion_error(
986 format!("Error writing Xcstrings output: {}", e),
987 None,
988 )
989 })
990 }
991 crate::formats::FormatType::CSV => CSVFormat::try_from(vec![resource.clone()])
992 .and_then(|f| f.write_to(Path::new(output_path)))
993 .map_err(|e| {
994 Error::conversion_error(format!("Error writing CSV output: {}", e), None)
995 }),
996 crate::formats::FormatType::TSV => TSVFormat::try_from(vec![resource.clone()])
997 .and_then(|f| f.write_to(Path::new(output_path)))
998 .map_err(|e| {
999 Error::conversion_error(format!("Error writing TSV output: {}", e), None)
1000 }),
1001 }
1002 }
1003
1004 pub fn read_file_by_type<P: AsRef<Path>>(
1015 &mut self,
1016 path: P,
1017 format_type: FormatType,
1018 ) -> Result<(), Error> {
1019 let mut language = crate::converter::infer_language_from_path(&path, &format_type)?;
1020 if language.is_none() {
1022 match &format_type {
1023 FormatType::Strings(lang_opt) | FormatType::AndroidStrings(lang_opt) => {
1024 if let Some(l) = lang_opt {
1025 language = Some(l.clone());
1026 }
1027 }
1028 _ => {}
1029 }
1030 }
1031
1032 let domain = path
1033 .as_ref()
1034 .file_stem()
1035 .and_then(|s| s.to_str())
1036 .unwrap_or_default()
1037 .to_string();
1038 let path = path.as_ref();
1039
1040 let mut new_resources = match &format_type {
1041 FormatType::Strings(_) => {
1042 vec![Resource::from(StringsFormat::read_from(path)?)]
1043 }
1044 FormatType::AndroidStrings(_) => {
1045 vec![Resource::from(AndroidStringsFormat::read_from(path)?)]
1046 }
1047 FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(path)?)?,
1048 FormatType::CSV => {
1049 let csv_format = CSVFormat::read_from(path)?;
1051 Vec::<Resource>::try_from(csv_format)?
1052 }
1053 FormatType::TSV => {
1054 let tsv_format = TSVFormat::read_from(path)?;
1056 Vec::<Resource>::try_from(tsv_format)?
1057 }
1058 };
1059
1060 for new_resource in &mut new_resources {
1061 if let Some(ref lang) = language {
1062 new_resource.metadata.language = lang.clone();
1063 }
1064 new_resource.metadata.domain = domain.clone();
1065 new_resource
1066 .metadata
1067 .custom
1068 .insert("format".to_string(), format_type.to_string());
1069 }
1070 self.resources.append(&mut new_resources);
1071
1072 Ok(())
1073 }
1074
1075 pub fn read_file_by_extension<P: AsRef<Path>>(
1087 &mut self,
1088 path: P,
1089 lang: Option<String>,
1090 ) -> Result<(), Error> {
1091 let format_type = match path.as_ref().extension().and_then(|s| s.to_str()) {
1092 Some("xml") => FormatType::AndroidStrings(lang),
1093 Some("strings") => FormatType::Strings(lang),
1094 Some("xcstrings") => FormatType::Xcstrings,
1095 Some("csv") => FormatType::CSV,
1096 Some("tsv") => FormatType::TSV,
1097 extension => {
1098 return Err(Error::UnsupportedFormat(format!(
1099 "Unsupported file extension: {:?}.",
1100 extension
1101 )));
1102 }
1103 };
1104
1105 self.read_file_by_type(path, format_type)?;
1106
1107 Ok(())
1108 }
1109
1110 pub fn write_to_file(&self) -> Result<(), Error> {
1117 let mut grouped_resources: std::collections::HashMap<String, Vec<Resource>> =
1119 std::collections::HashMap::new();
1120 for resource in &*self.resources {
1121 let domain = resource.metadata.domain.clone();
1122 grouped_resources
1123 .entry(domain)
1124 .or_default()
1125 .push(resource.clone());
1126 }
1127
1128 for (domain, resources) in grouped_resources {
1130 crate::converter::write_resources_to_file(&resources, &domain)?;
1131 }
1132
1133 Ok(())
1134 }
1135
1136 pub fn cache_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
1145 let path = path.as_ref();
1146 if let Some(parent) = path.parent() {
1147 std::fs::create_dir_all(parent).map_err(Error::Io)?;
1148 }
1149 let mut writer = std::fs::File::create(path).map_err(Error::Io)?;
1150 serde_json::to_writer(&mut writer, &*self.resources).map_err(Error::Parse)?;
1151 Ok(())
1152 }
1153
1154 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
1163 let mut reader = std::fs::File::open(path).map_err(Error::Io)?;
1164 let resources: Vec<Resource> =
1165 serde_json::from_reader(&mut reader).map_err(Error::Parse)?;
1166 Ok(Codec { resources })
1167 }
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173 use crate::types::{Entry, EntryStatus, Metadata, Translation};
1174 use std::collections::HashMap;
1175
1176 #[test]
1177 fn test_builder_pattern() {
1178 let codec = Codec::builder().build();
1180 assert_eq!(codec.resources.len(), 0);
1181
1182 let resource1 = Resource {
1184 metadata: Metadata {
1185 language: "en".to_string(),
1186 domain: "test".to_string(),
1187 custom: std::collections::HashMap::new(),
1188 },
1189 entries: vec![Entry {
1190 id: "hello".to_string(),
1191 value: Translation::Singular("Hello".to_string()),
1192 comment: None,
1193 status: EntryStatus::Translated,
1194 custom: std::collections::HashMap::new(),
1195 }],
1196 };
1197
1198 let resource2 = Resource {
1199 metadata: Metadata {
1200 language: "fr".to_string(),
1201 domain: "test".to_string(),
1202 custom: std::collections::HashMap::new(),
1203 },
1204 entries: vec![Entry {
1205 id: "hello".to_string(),
1206 value: Translation::Singular("Bonjour".to_string()),
1207 comment: None,
1208 status: EntryStatus::Translated,
1209 custom: std::collections::HashMap::new(),
1210 }],
1211 };
1212
1213 let codec = Codec::builder()
1214 .add_resource(resource1.clone())
1215 .add_resource(resource2.clone())
1216 .build();
1217
1218 assert_eq!(codec.resources.len(), 2);
1219 assert_eq!(codec.resources[0].metadata.language, "en");
1220 assert_eq!(codec.resources[1].metadata.language, "fr");
1221 }
1222
1223 #[test]
1224 fn test_builder_validation() {
1225 let resource_without_language = Resource {
1227 metadata: Metadata {
1228 language: "".to_string(),
1229 domain: "test".to_string(),
1230 custom: std::collections::HashMap::new(),
1231 },
1232 entries: vec![],
1233 };
1234
1235 let result = Codec::builder()
1236 .add_resource(resource_without_language)
1237 .build_and_validate();
1238
1239 assert!(result.is_err());
1240 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1241
1242 let resource1 = Resource {
1244 metadata: Metadata {
1245 language: "en".to_string(),
1246 domain: "test".to_string(),
1247 custom: std::collections::HashMap::new(),
1248 },
1249 entries: vec![],
1250 };
1251
1252 let resource2 = Resource {
1253 metadata: Metadata {
1254 language: "en".to_string(), domain: "test".to_string(),
1256 custom: std::collections::HashMap::new(),
1257 },
1258 entries: vec![],
1259 };
1260
1261 let result = Codec::builder()
1262 .add_resource(resource1)
1263 .add_resource(resource2)
1264 .build_and_validate();
1265
1266 assert!(result.is_err());
1267 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1268 }
1269
1270 #[test]
1271 fn test_builder_add_resources() {
1272 let resources = vec![
1273 Resource {
1274 metadata: Metadata {
1275 language: "en".to_string(),
1276 domain: "test".to_string(),
1277 custom: std::collections::HashMap::new(),
1278 },
1279 entries: vec![],
1280 },
1281 Resource {
1282 metadata: Metadata {
1283 language: "fr".to_string(),
1284 domain: "test".to_string(),
1285 custom: std::collections::HashMap::new(),
1286 },
1287 entries: vec![],
1288 },
1289 ];
1290
1291 let codec = Codec::builder().add_resources(resources).build();
1292 assert_eq!(codec.resources.len(), 2);
1293 assert_eq!(codec.resources[0].metadata.language, "en");
1294 assert_eq!(codec.resources[1].metadata.language, "fr");
1295 }
1296
1297 #[test]
1298 fn test_modification_methods() {
1299 use crate::types::{EntryStatus, Translation};
1300
1301 let mut codec = Codec::new();
1303
1304 let resource1 = Resource {
1306 metadata: Metadata {
1307 language: "en".to_string(),
1308 domain: "test".to_string(),
1309 custom: std::collections::HashMap::new(),
1310 },
1311 entries: vec![Entry {
1312 id: "welcome".to_string(),
1313 value: Translation::Singular("Hello".to_string()),
1314 comment: None,
1315 status: EntryStatus::Translated,
1316 custom: std::collections::HashMap::new(),
1317 }],
1318 };
1319
1320 let resource2 = Resource {
1321 metadata: Metadata {
1322 language: "fr".to_string(),
1323 domain: "test".to_string(),
1324 custom: std::collections::HashMap::new(),
1325 },
1326 entries: vec![Entry {
1327 id: "welcome".to_string(),
1328 value: Translation::Singular("Bonjour".to_string()),
1329 comment: None,
1330 status: EntryStatus::Translated,
1331 custom: std::collections::HashMap::new(),
1332 }],
1333 };
1334
1335 codec.add_resource(resource1);
1336 codec.add_resource(resource2);
1337
1338 let entries = codec.find_entries("welcome");
1340 assert_eq!(entries.len(), 2);
1341 assert_eq!(entries[0].0.metadata.language, "en");
1342 assert_eq!(entries[1].0.metadata.language, "fr");
1343
1344 let entry = codec.find_entry("welcome", "en");
1346 assert!(entry.is_some());
1347 assert_eq!(entry.unwrap().id, "welcome");
1348
1349 if let Some(entry) = codec.find_entry_mut("welcome", "en") {
1351 entry.value = Translation::Singular("Hello, World!".to_string());
1352 entry.status = EntryStatus::NeedsReview;
1353 }
1354
1355 let updated_entry = codec.find_entry("welcome", "en").unwrap();
1357 assert_eq!(updated_entry.value.to_string(), "Hello, World!");
1358 assert_eq!(updated_entry.status, EntryStatus::NeedsReview);
1359
1360 codec
1362 .update_translation(
1363 "welcome",
1364 "fr",
1365 Translation::Singular("Bonjour, le monde!".to_string()),
1366 Some(EntryStatus::NeedsReview),
1367 )
1368 .unwrap();
1369
1370 codec
1372 .add_entry(
1373 "new_key",
1374 "en",
1375 Translation::Singular("New message".to_string()),
1376 Some("A new message".to_string()),
1377 Some(EntryStatus::New),
1378 )
1379 .unwrap();
1380
1381 assert!(codec.has_entry("new_key", "en"));
1382 assert_eq!(codec.entry_count("en"), 2);
1383
1384 codec.remove_entry("new_key", "en").unwrap();
1386 assert!(!codec.has_entry("new_key", "en"));
1387 assert_eq!(codec.entry_count("en"), 1);
1388
1389 codec.copy_entry("welcome", "en", "fr", true).unwrap();
1391 let copied_entry = codec.find_entry("welcome", "fr").unwrap();
1392 assert_eq!(copied_entry.status, EntryStatus::New);
1393
1394 let languages: Vec<_> = codec.languages().collect();
1396 assert_eq!(languages.len(), 2);
1397 assert!(languages.contains(&"en"));
1398 assert!(languages.contains(&"fr"));
1399
1400 let keys: Vec<_> = codec.all_keys().collect();
1402 assert_eq!(keys.len(), 1);
1403 assert!(keys.contains(&"welcome"));
1404 }
1405
1406 #[test]
1407 fn test_validation() {
1408 let mut codec = Codec::new();
1409
1410 let resource_without_language = Resource {
1412 metadata: Metadata {
1413 language: "".to_string(),
1414 domain: "test".to_string(),
1415 custom: std::collections::HashMap::new(),
1416 },
1417 entries: vec![],
1418 };
1419
1420 codec.add_resource(resource_without_language);
1421 assert!(codec.validate().is_err());
1422
1423 let mut codec = Codec::new();
1425 let resource1 = Resource {
1426 metadata: Metadata {
1427 language: "en".to_string(),
1428 domain: "test".to_string(),
1429 custom: std::collections::HashMap::new(),
1430 },
1431 entries: vec![],
1432 };
1433
1434 let resource2 = Resource {
1435 metadata: Metadata {
1436 language: "en".to_string(), domain: "test".to_string(),
1438 custom: std::collections::HashMap::new(),
1439 },
1440 entries: vec![],
1441 };
1442
1443 codec.add_resource(resource1);
1444 codec.add_resource(resource2);
1445 assert!(codec.validate().is_err());
1446
1447 let mut codec = Codec::new();
1449 let resource1 = Resource {
1450 metadata: Metadata {
1451 language: "en".to_string(),
1452 domain: "test".to_string(),
1453 custom: std::collections::HashMap::new(),
1454 },
1455 entries: vec![Entry {
1456 id: "welcome".to_string(),
1457 value: Translation::Singular("Hello".to_string()),
1458 comment: None,
1459 status: EntryStatus::Translated,
1460 custom: std::collections::HashMap::new(),
1461 }],
1462 };
1463
1464 let resource2 = Resource {
1465 metadata: Metadata {
1466 language: "fr".to_string(),
1467 domain: "test".to_string(),
1468 custom: std::collections::HashMap::new(),
1469 },
1470 entries: vec![], };
1472
1473 codec.add_resource(resource1);
1474 codec.add_resource(resource2);
1475 assert!(codec.validate().is_err());
1476 }
1477
1478 #[test]
1479 fn test_convert_csv_to_xcstrings() {
1480 let temp_dir = tempfile::tempdir().unwrap();
1482 let input_file = temp_dir.path().join("test.csv");
1483 let output_file = temp_dir.path().join("output.xcstrings");
1484
1485 let csv_content =
1486 "key,en,fr,de\nhello,Hello,Bonjour,Hallo\nbye,Goodbye,Au revoir,Auf Wiedersehen\n";
1487 std::fs::write(&input_file, csv_content).unwrap();
1488
1489 let csv_format = CSVFormat::read_from(&input_file).unwrap();
1491 let resources = Vec::<Resource>::try_from(csv_format).unwrap();
1492 println!("CSV parsed to {} resources:", resources.len());
1493 for (i, resource) in resources.iter().enumerate() {
1494 println!(
1495 " Resource {}: language={}, entries={}",
1496 i,
1497 resource.metadata.language,
1498 resource.entries.len()
1499 );
1500 for entry in &resource.entries {
1501 println!(" Entry: id={}, value={:?}", entry.id, entry.value);
1502 }
1503 }
1504
1505 let result = crate::converter::convert(
1506 &input_file,
1507 FormatType::CSV,
1508 &output_file,
1509 FormatType::Xcstrings,
1510 );
1511
1512 match result {
1513 Ok(()) => println!("✅ CSV to XCStrings conversion succeeded"),
1514 Err(e) => println!("❌ CSV to XCStrings conversion failed: {}", e),
1515 }
1516
1517 if output_file.exists() {
1519 let content = std::fs::read_to_string(&output_file).unwrap();
1520 println!("Output file content: {}", content);
1521 }
1522
1523 let _ = std::fs::remove_file(input_file);
1525 let _ = std::fs::remove_file(output_file);
1526 }
1527
1528 #[test]
1529 fn test_merge_resources_method() {
1530 use crate::types::{ConflictStrategy, Entry, EntryStatus, Metadata, Translation};
1531
1532 let mut codec = Codec::new();
1533
1534 let resource1 = Resource {
1536 metadata: Metadata {
1537 language: "en".to_string(),
1538 domain: "domain1".to_string(),
1539 custom: HashMap::new(),
1540 },
1541 entries: vec![Entry {
1542 id: "hello".to_string(),
1543 value: Translation::Singular("Hello".to_string()),
1544 comment: None,
1545 status: EntryStatus::Translated,
1546 custom: HashMap::new(),
1547 }],
1548 };
1549
1550 let resource2 = Resource {
1551 metadata: Metadata {
1552 language: "en".to_string(),
1553 domain: "domain2".to_string(),
1554 custom: HashMap::new(),
1555 },
1556 entries: vec![Entry {
1557 id: "goodbye".to_string(),
1558 value: Translation::Singular("Goodbye".to_string()),
1559 comment: None,
1560 status: EntryStatus::Translated,
1561 custom: HashMap::new(),
1562 }],
1563 };
1564
1565 let resource3 = Resource {
1566 metadata: Metadata {
1567 language: "en".to_string(),
1568 domain: "domain3".to_string(),
1569 custom: HashMap::new(),
1570 },
1571 entries: vec![Entry {
1572 id: "hello".to_string(), value: Translation::Singular("Hi".to_string()),
1574 comment: None,
1575 status: EntryStatus::Translated,
1576 custom: HashMap::new(),
1577 }],
1578 };
1579
1580 codec.add_resource(resource1);
1582 codec.add_resource(resource2);
1583 codec.add_resource(resource3);
1584
1585 let merges_performed = codec.merge_resources(&ConflictStrategy::Last);
1587 assert_eq!(merges_performed, 1); assert_eq!(codec.resources.len(), 1); let merged_resource = &codec.resources[0];
1591 assert_eq!(merged_resource.metadata.language, "en");
1592 assert_eq!(merged_resource.entries.len(), 2); let hello_entry = merged_resource
1596 .entries
1597 .iter()
1598 .find(|e| e.id == "hello")
1599 .unwrap();
1600 assert_eq!(hello_entry.value.plain_translation_string(), "Hi");
1601 }
1602
1603 #[test]
1604 fn test_merge_resources_method_multiple_languages() {
1605 use crate::types::{ConflictStrategy, Entry, EntryStatus, Metadata, Translation};
1606
1607 let mut codec = Codec::new();
1608
1609 let en_resource1 = Resource {
1611 metadata: Metadata {
1612 language: "en".to_string(),
1613 domain: "domain1".to_string(),
1614 custom: HashMap::new(),
1615 },
1616 entries: vec![Entry {
1617 id: "hello".to_string(),
1618 value: Translation::Singular("Hello".to_string()),
1619 comment: None,
1620 status: EntryStatus::Translated,
1621 custom: HashMap::new(),
1622 }],
1623 };
1624
1625 let en_resource2 = Resource {
1626 metadata: Metadata {
1627 language: "en".to_string(),
1628 domain: "domain2".to_string(),
1629 custom: HashMap::new(),
1630 },
1631 entries: vec![Entry {
1632 id: "goodbye".to_string(),
1633 value: Translation::Singular("Goodbye".to_string()),
1634 comment: None,
1635 status: EntryStatus::Translated,
1636 custom: HashMap::new(),
1637 }],
1638 };
1639
1640 let fr_resource = Resource {
1642 metadata: Metadata {
1643 language: "fr".to_string(),
1644 domain: "domain1".to_string(),
1645 custom: HashMap::new(),
1646 },
1647 entries: vec![Entry {
1648 id: "bonjour".to_string(),
1649 value: Translation::Singular("Bonjour".to_string()),
1650 comment: None,
1651 status: EntryStatus::Translated,
1652 custom: HashMap::new(),
1653 }],
1654 };
1655
1656 codec.add_resource(en_resource1);
1658 codec.add_resource(en_resource2);
1659 codec.add_resource(fr_resource);
1660
1661 let merges_performed = codec.merge_resources(&ConflictStrategy::First);
1663 assert_eq!(merges_performed, 1); assert_eq!(codec.resources.len(), 2); let en_resource = codec.get_by_language("en").unwrap();
1668 assert_eq!(en_resource.entries.len(), 2);
1669
1670 let fr_resource = codec.get_by_language("fr").unwrap();
1672 assert_eq!(fr_resource.entries.len(), 1);
1673 assert_eq!(fr_resource.entries[0].id, "bonjour");
1674 }
1675
1676 #[test]
1677 fn test_merge_resources_method_no_merges() {
1678 use crate::types::{ConflictStrategy, Entry, EntryStatus, Metadata, Translation};
1679
1680 let mut codec = Codec::new();
1681
1682 let en_resource = Resource {
1684 metadata: Metadata {
1685 language: "en".to_string(),
1686 domain: "domain1".to_string(),
1687 custom: HashMap::new(),
1688 },
1689 entries: vec![Entry {
1690 id: "hello".to_string(),
1691 value: Translation::Singular("Hello".to_string()),
1692 comment: None,
1693 status: EntryStatus::Translated,
1694 custom: HashMap::new(),
1695 }],
1696 };
1697
1698 let fr_resource = Resource {
1699 metadata: Metadata {
1700 language: "fr".to_string(),
1701 domain: "domain1".to_string(),
1702 custom: HashMap::new(),
1703 },
1704 entries: vec![Entry {
1705 id: "bonjour".to_string(),
1706 value: Translation::Singular("Bonjour".to_string()),
1707 comment: None,
1708 status: EntryStatus::Translated,
1709 custom: HashMap::new(),
1710 }],
1711 };
1712
1713 codec.add_resource(en_resource);
1715 codec.add_resource(fr_resource);
1716
1717 let merges_performed = codec.merge_resources(&ConflictStrategy::Last);
1719 assert_eq!(merges_performed, 0); assert_eq!(codec.resources.len(), 2); assert!(codec.get_by_language("en").is_some());
1724 assert!(codec.get_by_language("fr").is_some());
1725 }
1726
1727 #[test]
1728 fn test_merge_resources_method_empty_codec() {
1729 let mut codec = Codec::new();
1730
1731 let merges_performed = codec.merge_resources(&ConflictStrategy::Last);
1733 assert_eq!(merges_performed, 0);
1734 assert_eq!(codec.resources.len(), 0);
1735 }
1736
1737 #[test]
1738 fn test_extend_from_and_from_codecs() {
1739 let mut codec1 = Codec::new();
1740 let mut codec2 = Codec::new();
1741
1742 let en_resource = Resource {
1743 metadata: Metadata {
1744 language: "en".to_string(),
1745 domain: "d1".to_string(),
1746 custom: HashMap::new(),
1747 },
1748 entries: vec![Entry {
1749 id: "hello".to_string(),
1750 value: Translation::Singular("Hello".to_string()),
1751 comment: None,
1752 status: EntryStatus::Translated,
1753 custom: HashMap::new(),
1754 }],
1755 };
1756
1757 let fr_resource = Resource {
1758 metadata: Metadata {
1759 language: "fr".to_string(),
1760 domain: "d2".to_string(),
1761 custom: HashMap::new(),
1762 },
1763 entries: vec![Entry {
1764 id: "bonjour".to_string(),
1765 value: Translation::Singular("Bonjour".to_string()),
1766 comment: None,
1767 status: EntryStatus::Translated,
1768 custom: HashMap::new(),
1769 }],
1770 };
1771
1772 codec1.add_resource(en_resource);
1773 codec2.add_resource(fr_resource);
1774
1775 let mut combined = codec1;
1777 combined.extend_from(codec2);
1778 assert_eq!(combined.resources.len(), 2);
1779
1780 let c = Codec::from_codecs(vec![combined.clone()]);
1782 assert_eq!(c.resources.len(), 2);
1783 }
1784
1785 #[test]
1786 fn test_merge_codecs_across_instances() {
1787 use crate::types::ConflictStrategy;
1788
1789 let mut c1 = Codec::new();
1791 let mut c2 = Codec::new();
1792
1793 c1.add_resource(Resource {
1794 metadata: Metadata {
1795 language: "en".to_string(),
1796 domain: "d1".to_string(),
1797 custom: HashMap::new(),
1798 },
1799 entries: vec![Entry {
1800 id: "hello".to_string(),
1801 value: Translation::Singular("Hello".to_string()),
1802 comment: None,
1803 status: EntryStatus::Translated,
1804 custom: HashMap::new(),
1805 }],
1806 });
1807
1808 c2.add_resource(Resource {
1809 metadata: Metadata {
1810 language: "en".to_string(),
1811 domain: "d2".to_string(),
1812 custom: HashMap::new(),
1813 },
1814 entries: vec![Entry {
1815 id: "goodbye".to_string(),
1816 value: Translation::Singular("Goodbye".to_string()),
1817 comment: None,
1818 status: EntryStatus::Translated,
1819 custom: HashMap::new(),
1820 }],
1821 });
1822
1823 let merged = Codec::merge_codecs(vec![c1, c2], &ConflictStrategy::Last);
1824 assert_eq!(merged.resources.len(), 1);
1825 assert_eq!(merged.resources[0].metadata.language, "en");
1826 assert_eq!(merged.resources[0].entries.len(), 2);
1827 }
1828
1829 #[test]
1830 fn test_validate_placeholders_across_languages() {
1831 let mut codec = Codec::new();
1832 codec.add_resource(Resource {
1834 metadata: Metadata {
1835 language: "en".into(),
1836 domain: "d".into(),
1837 custom: HashMap::new(),
1838 },
1839 entries: vec![Entry {
1840 id: "greet".into(),
1841 value: Translation::Singular("Hello %1$@".into()),
1842 comment: None,
1843 status: EntryStatus::Translated,
1844 custom: HashMap::new(),
1845 }],
1846 });
1847 codec.add_resource(Resource {
1848 metadata: Metadata {
1849 language: "fr".into(),
1850 domain: "d".into(),
1851 custom: HashMap::new(),
1852 },
1853 entries: vec![Entry {
1854 id: "greet".into(),
1855 value: Translation::Singular("Bonjour %1$s".into()),
1856 comment: None,
1857 status: EntryStatus::Translated,
1858 custom: HashMap::new(),
1859 }],
1860 });
1861 assert!(codec.validate_placeholders(true).is_ok());
1862 }
1863
1864 #[test]
1865 fn test_validate_placeholders_mismatch() {
1866 let mut codec = Codec::new();
1867 codec.add_resource(Resource {
1868 metadata: Metadata {
1869 language: "en".into(),
1870 domain: "d".into(),
1871 custom: HashMap::new(),
1872 },
1873 entries: vec![Entry {
1874 id: "count".into(),
1875 value: Translation::Singular("%d files".into()),
1876 comment: None,
1877 status: EntryStatus::Translated,
1878 custom: HashMap::new(),
1879 }],
1880 });
1881 codec.add_resource(Resource {
1882 metadata: Metadata {
1883 language: "fr".into(),
1884 domain: "d".into(),
1885 custom: HashMap::new(),
1886 },
1887 entries: vec![Entry {
1888 id: "count".into(),
1889 value: Translation::Singular("%s fichiers".into()),
1890 comment: None,
1891 status: EntryStatus::Translated,
1892 custom: HashMap::new(),
1893 }],
1894 });
1895 assert!(codec.validate_placeholders(true).is_err());
1896 }
1897
1898 #[test]
1899 fn test_collect_placeholder_issues_non_strict_ok() {
1900 let mut codec = Codec::new();
1901 codec.add_resource(Resource {
1902 metadata: Metadata {
1903 language: "en".into(),
1904 domain: "d".into(),
1905 custom: HashMap::new(),
1906 },
1907 entries: vec![Entry {
1908 id: "count".into(),
1909 value: Translation::Singular("%d files".into()),
1910 comment: None,
1911 status: EntryStatus::Translated,
1912 custom: HashMap::new(),
1913 }],
1914 });
1915 codec.add_resource(Resource {
1916 metadata: Metadata {
1917 language: "fr".into(),
1918 domain: "d".into(),
1919 custom: HashMap::new(),
1920 },
1921 entries: vec![Entry {
1922 id: "count".into(),
1923 value: Translation::Singular("%s fichiers".into()),
1924 comment: None,
1925 status: EntryStatus::Translated,
1926 custom: HashMap::new(),
1927 }],
1928 });
1929 assert!(codec.validate_placeholders(false).is_ok());
1931 let issues = codec.collect_placeholder_issues();
1932 assert!(!issues.is_empty());
1933 }
1934
1935 #[test]
1936 fn test_normalize_placeholders_in_place() {
1937 let mut codec = Codec::new();
1938 codec.add_resource(Resource {
1939 metadata: Metadata {
1940 language: "en".into(),
1941 domain: "d".into(),
1942 custom: HashMap::new(),
1943 },
1944 entries: vec![Entry {
1945 id: "g".into(),
1946 value: Translation::Singular("Hello %@ and %1$@".into()),
1947 comment: None,
1948 status: EntryStatus::Translated,
1949 custom: HashMap::new(),
1950 }],
1951 });
1952 codec.normalize_placeholders_in_place();
1953 let v = match &codec.resources[0].entries[0].value {
1954 Translation::Singular(v) => v.clone(),
1955 _ => String::new(),
1956 };
1957 assert!(v.contains("%s"));
1958 assert!(v.contains("%1$s"));
1959 }
1960}