1use crate::formats::{CSVFormat, TSVFormat};
11use crate::{ConflictStrategy, merge_resources};
12use crate::{
13 error::Error,
14 formats::*,
15 provenance::{ProvenanceRecord, set_resource_provenance},
16 read_options::ReadOptions,
17 traits::Parser,
18 types::{Entry, Resource},
19};
20use std::path::Path;
21
22#[derive(Debug, Clone)]
25pub struct Codec {
26 pub resources: Vec<Resource>,
28}
29
30impl Default for Codec {
31 fn default() -> Self {
32 Codec::new()
33 }
34}
35
36impl Codec {
37 pub fn new() -> Self {
43 Codec {
44 resources: Vec::new(),
45 }
46 }
47
48 pub fn builder() -> crate::builder::CodecBuilder {
69 crate::builder::CodecBuilder::new()
70 }
71
72 pub fn iter(&self) -> std::slice::Iter<'_, Resource> {
74 self.resources.iter()
75 }
76
77 pub fn iter_mut(&mut self) -> std::slice::IterMut<'_, Resource> {
79 self.resources.iter_mut()
80 }
81
82 pub fn get_by_language(&self, lang: &str) -> Option<&Resource> {
84 self.resources
85 .iter()
86 .find(|res| res.metadata.language == lang)
87 }
88
89 pub fn get_mut_by_language(&mut self, lang: &str) -> Option<&mut Resource> {
91 self.resources
92 .iter_mut()
93 .find(|res| res.metadata.language == lang)
94 }
95
96 pub fn add_resource(&mut self, resource: Resource) {
98 self.resources.push(resource);
99 }
100
101 pub fn extend_from(&mut self, mut other: Codec) {
103 self.resources.append(&mut other.resources);
104 }
105
106 pub fn from_codecs<I>(codecs: I) -> Self
108 where
109 I: IntoIterator<Item = Codec>,
110 {
111 let mut combined = Codec::new();
112 for mut c in codecs {
113 combined.resources.append(&mut c.resources);
114 }
115 combined
116 }
117
118 pub fn merge_codecs<I>(codecs: I, strategy: &ConflictStrategy) -> Self
122 where
123 I: IntoIterator<Item = Codec>,
124 {
125 let mut combined = Codec::from_codecs(codecs);
126 let _ = combined.merge_resources(strategy);
127 combined
128 }
129
130 pub fn find_entries(&self, key: &str) -> Vec<(&Resource, &Entry)> {
157 let mut results = Vec::new();
158 for resource in &self.resources {
159 if let Some(entry) = resource.find_entry(key) {
160 results.push((resource, entry));
161 }
162 }
163 results
164 }
165
166 pub fn find_entry(&self, key: &str, language: &str) -> Option<&Entry> {
190 self.get_by_language(language)?.find_entry(key)
191 }
192
193 pub fn find_entry_mut(&mut self, key: &str, language: &str) -> Option<&mut Entry> {
219 self.get_mut_by_language(language)?.find_entry_mut(key)
220 }
221
222 pub fn update_translation(
253 &mut self,
254 key: &str,
255 language: &str,
256 translation: crate::types::Translation,
257 status: Option<crate::types::EntryStatus>,
258 ) -> Result<(), Error> {
259 if let Some(entry) = self.find_entry_mut(key, language) {
260 entry.value = translation;
261 if let Some(new_status) = status {
262 entry.status = new_status;
263 }
264 Ok(())
265 } else {
266 Err(Error::InvalidResource(format!(
267 "Entry '{}' not found in language '{}'",
268 key, language
269 )))
270 }
271 }
272
273 pub fn add_entry(
306 &mut self,
307 key: &str,
308 language: &str,
309 translation: crate::types::Translation,
310 comment: Option<String>,
311 status: Option<crate::types::EntryStatus>,
312 ) -> Result<(), Error> {
313 let resource = if let Some(resource) = self.get_mut_by_language(language) {
315 resource
316 } else {
317 let new_resource = crate::types::Resource {
319 metadata: crate::types::Metadata {
320 language: language.to_string(),
321 domain: "".to_string(),
322 custom: std::collections::HashMap::new(),
323 },
324 entries: Vec::new(),
325 };
326 self.add_resource(new_resource);
327 self.get_mut_by_language(language).unwrap()
328 };
329
330 let entry = crate::types::Entry {
331 id: key.to_string(),
332 value: translation,
333 comment,
334 status: status.unwrap_or(crate::types::EntryStatus::New),
335 custom: std::collections::HashMap::new(),
336 };
337 resource.add_entry(entry);
338 Ok(())
339 }
340
341 pub fn remove_entry(&mut self, key: &str, language: &str) -> Result<(), Error> {
366 if let Some(resource) = self.get_mut_by_language(language) {
367 let initial_len = resource.entries.len();
368 resource.entries.retain(|entry| entry.id != key);
369
370 if resource.entries.len() == initial_len {
371 Err(Error::InvalidResource(format!(
372 "Entry '{}' not found in language '{}'",
373 key, language
374 )))
375 } else {
376 Ok(())
377 }
378 } else {
379 Err(Error::InvalidResource(format!(
380 "Language '{}' not found",
381 language
382 )))
383 }
384 }
385
386 pub fn copy_entry(
415 &mut self,
416 key: &str,
417 from_language: &str,
418 to_language: &str,
419 update_status: bool,
420 ) -> Result<(), Error> {
421 let source_entry = self.find_entry(key, from_language).ok_or_else(|| {
422 Error::InvalidResource(format!(
423 "Entry '{}' not found in source language '{}'",
424 key, from_language
425 ))
426 })?;
427
428 let mut new_entry = source_entry.clone();
429 if update_status {
430 new_entry.status = crate::types::EntryStatus::New;
431 }
432
433 let target_resource = if let Some(resource) = self.get_mut_by_language(to_language) {
435 resource
436 } else {
437 let new_resource = crate::types::Resource {
439 metadata: crate::types::Metadata {
440 language: to_language.to_string(),
441 domain: "".to_string(),
442 custom: std::collections::HashMap::new(),
443 },
444 entries: Vec::new(),
445 };
446 self.add_resource(new_resource);
447 self.get_mut_by_language(to_language).unwrap()
448 };
449
450 target_resource.entries.retain(|entry| entry.id != key);
452 target_resource.add_entry(new_entry);
453 Ok(())
454 }
455
456 pub fn languages(&self) -> impl Iterator<Item = &str> {
475 self.resources.iter().map(|r| r.metadata.language.as_str())
476 }
477
478 pub fn all_keys(&self) -> impl Iterator<Item = &str> {
497 use std::collections::HashSet;
498
499 let mut keys = HashSet::new();
500 for resource in &self.resources {
501 for entry in &resource.entries {
502 keys.insert(entry.id.as_str());
503 }
504 }
505 keys.into_iter()
506 }
507
508 pub fn has_entry(&self, key: &str, language: &str) -> bool {
532 self.find_entry(key, language).is_some()
533 }
534
535 pub fn entry_count(&self, language: &str) -> usize {
557 self.get_by_language(language)
558 .map(|r| r.entries.len())
559 .unwrap_or(0)
560 }
561
562 pub fn validate(&self) -> Result<(), Error> {
581 if self.resources.is_empty() {
583 return Err(Error::InvalidResource("No resources found".to_string()));
584 }
585
586 let mut languages = std::collections::HashSet::new();
588 for resource in &self.resources {
589 if !languages.insert(&resource.metadata.language) {
590 return Err(Error::InvalidResource(format!(
591 "Duplicate language found: {}",
592 resource.metadata.language
593 )));
594 }
595 }
596
597 for resource in &self.resources {
599 if resource.entries.is_empty() {
600 return Err(Error::InvalidResource(format!(
601 "Resource for language '{}' has no entries",
602 resource.metadata.language
603 )));
604 }
605 }
606
607 Ok(())
608 }
609
610 pub fn validate_plurals(&self) -> Result<(), Error> {
616 use crate::plural_rules::collect_resource_plural_issues;
617
618 let mut reports = Vec::new();
619 for res in &self.resources {
620 reports.extend(collect_resource_plural_issues(res));
621 }
622
623 if reports.is_empty() {
624 return Ok(());
625 }
626
627 let mut lines = Vec::new();
629 for r in reports {
630 let miss: Vec<String> = r.missing.iter().map(|k| format!("{:?}", k)).collect();
631 let have: Vec<String> = r.have.iter().map(|k| format!("{:?}", k)).collect();
632 lines.push(format!(
633 "lang='{}' key='{}': missing plural categories: [{}] (have: [{}])",
634 r.language,
635 r.key,
636 miss.join(", "),
637 have.join(", ")
638 ));
639 }
640 Err(Error::validation_error(lines.join("\n")))
641 }
642
643 pub fn collect_plural_issues(&self) -> Vec<crate::plural_rules::PluralValidationReport> {
645 use crate::plural_rules::collect_resource_plural_issues;
646 let mut reports = Vec::new();
647 for res in &self.resources {
648 reports.extend(collect_resource_plural_issues(res));
649 }
650 reports
651 }
652
653 pub fn autofix_fill_missing_from_other(&mut self) -> usize {
656 use crate::plural_rules::autofix_fill_missing_from_other_resource;
657 let mut total = 0usize;
658 for res in &mut self.resources {
659 total += autofix_fill_missing_from_other_resource(res);
660 }
661 total
662 }
663
664 pub fn clean_up_resources(&mut self) {
666 self.resources
667 .retain(|resource| !resource.entries.is_empty());
668 }
669
670 pub fn validate_placeholders(&self, strict: bool) -> Result<(), Error> {
694 use crate::placeholder::signature;
695 use crate::types::Translation;
696 use std::collections::HashMap;
697
698 let mut map: HashMap<String, HashMap<String, Vec<Vec<String>>>> = HashMap::new();
700
701 for res in &self.resources {
702 for entry in &res.entries {
703 let sigs: Vec<Vec<String>> = match &entry.value {
704 Translation::Empty => vec![],
705 Translation::Singular(v) => vec![signature(v)],
706 Translation::Plural(p) => p.forms.values().map(|v| signature(v)).collect(),
707 };
708 map.entry(entry.id.clone())
709 .or_default()
710 .entry(res.metadata.language.clone())
711 .or_default()
712 .push(sigs.into_iter().flatten().collect());
713 }
714 }
715
716 let mut problems = Vec::new();
717
718 for (key, langs) in map {
719 let mut per_lang_sig: HashMap<String, Vec<String>> = HashMap::new();
721 for (lang, sig_lists) in langs {
722 if let Some(first) = sig_lists.first() {
723 if sig_lists.iter().any(|s| s != first) {
724 problems.push(format!(
725 "Key '{}' in '{}': inconsistent placeholders across forms: {:?}",
726 key, lang, sig_lists
727 ));
728 }
729 per_lang_sig.insert(lang, first.clone());
730 }
731 }
732
733 if let Some((base_lang, base_sig)) = per_lang_sig.iter().next() {
735 for (lang, sig) in &per_lang_sig {
736 if sig != base_sig {
737 problems.push(format!(
738 "Key '{}' mismatch: {} {:?} vs {} {:?}",
739 key, base_lang, base_sig, lang, sig
740 ));
741 }
742 }
743 }
744 }
745
746 if problems.is_empty() {
747 return Ok(());
748 }
749 if strict {
750 return Err(Error::validation_error(format!(
751 "Placeholder issues: {}",
752 problems.join(" | ")
753 )));
754 }
755 Ok(())
757 }
758
759 pub fn collect_placeholder_issues(&self) -> Vec<String> {
764 use crate::placeholder::signature;
765 use crate::types::Translation;
766 use std::collections::HashMap;
767
768 let mut map: HashMap<String, HashMap<String, Vec<Vec<String>>>> = HashMap::new();
769 for res in &self.resources {
770 for entry in &res.entries {
771 let sigs: Vec<Vec<String>> = match &entry.value {
772 Translation::Empty => vec![],
773 Translation::Singular(v) => vec![signature(v)],
774 Translation::Plural(p) => p.forms.values().map(|v| signature(v)).collect(),
775 };
776 map.entry(entry.id.clone())
777 .or_default()
778 .entry(res.metadata.language.clone())
779 .or_default()
780 .push(sigs.into_iter().flatten().collect());
781 }
782 }
783
784 let mut problems = Vec::new();
785 for (key, langs) in map {
786 let mut per_lang_sig: HashMap<String, Vec<String>> = HashMap::new();
787 for (lang, sig_lists) in langs {
788 if let Some(first) = sig_lists.first() {
789 if sig_lists.iter().any(|s| s != first) {
790 problems.push(format!(
791 "Key '{}' in '{}': inconsistent placeholders across forms: {:?}",
792 key, lang, sig_lists
793 ));
794 }
795 per_lang_sig.insert(lang, first.clone());
796 }
797 }
798 if let Some((base_lang, base_sig)) = per_lang_sig.iter().next() {
799 for (lang, sig) in &per_lang_sig {
800 if sig != base_sig {
801 problems.push(format!(
802 "Key '{}' mismatch: {} {:?} vs {} {:?}",
803 key, base_lang, base_sig, lang, sig
804 ));
805 }
806 }
807 }
808 }
809 problems
810 }
811
812 pub fn normalize_placeholders_in_place(&mut self) {
828 use crate::placeholder::normalize_placeholders;
829 use crate::types::Translation;
830 for res in &mut self.resources {
831 for entry in &mut res.entries {
832 match &mut entry.value {
833 Translation::Empty => {
834 continue;
835 }
836 Translation::Singular(v) => {
837 let nv = normalize_placeholders(v);
838 *v = nv;
839 }
840 Translation::Plural(p) => {
841 for v in p.forms.values_mut() {
842 let nv = normalize_placeholders(v);
843 *v = nv;
844 }
845 }
846 }
847 }
848 }
849 }
850
851 pub fn merge_resources(&mut self, strategy: &ConflictStrategy) -> usize {
887 let mut grouped_resources: std::collections::HashMap<String, Vec<Resource>> =
889 std::collections::HashMap::new();
890 for resource in &self.resources {
891 grouped_resources
892 .entry(resource.metadata.language.clone())
893 .or_default()
894 .push(resource.clone());
895 }
896
897 let mut merge_count = 0;
898
899 for (_language, resources) in grouped_resources {
901 if resources.len() > 1 {
902 match merge_resources(&resources, strategy) {
903 Ok(merged) => {
904 self.resources.retain(|r| r.metadata.language != _language);
906 self.resources.push(merged);
907 merge_count += 1;
908 }
909 Err(e) => {
910 panic!("Unexpected error merging resources: {}", e);
914 }
915 }
916 }
917 }
918
919 merge_count
920 }
921
922 pub fn write_resource_to_file(resource: &Resource, output_path: &str) -> Result<(), Error> {
950 use crate::formats::{
951 AndroidStringsFormat, CSVFormat, StringsFormat, TSVFormat, XcstringsFormat,
952 };
953 use std::path::Path;
954
955 let format_type =
957 crate::converter::infer_format_from_extension(output_path).ok_or_else(|| {
958 Error::InvalidResource(format!(
959 "Cannot infer format from output path: {}",
960 output_path
961 ))
962 })?;
963
964 match format_type {
965 crate::formats::FormatType::AndroidStrings(_) => {
966 AndroidStringsFormat::from(resource.clone())
967 .write_to(Path::new(output_path))
968 .map_err(|e| {
969 Error::conversion_error(
970 format!("Error writing AndroidStrings output: {}", e),
971 None,
972 )
973 })
974 }
975 crate::formats::FormatType::Strings(_) => StringsFormat::try_from(resource.clone())
976 .and_then(|f| f.write_to(Path::new(output_path)))
977 .map_err(|e| {
978 Error::conversion_error(format!("Error writing Strings output: {}", e), None)
979 }),
980 crate::formats::FormatType::Xcstrings => {
981 XcstringsFormat::try_from(vec![resource.clone()])
982 .and_then(|f| f.write_to(Path::new(output_path)))
983 .map_err(|e| {
984 Error::conversion_error(
985 format!("Error writing Xcstrings output: {}", e),
986 None,
987 )
988 })
989 }
990 crate::formats::FormatType::CSV => CSVFormat::try_from(vec![resource.clone()])
991 .and_then(|f| f.write_to(Path::new(output_path)))
992 .map_err(|e| {
993 Error::conversion_error(format!("Error writing CSV output: {}", e), None)
994 }),
995 crate::formats::FormatType::TSV => TSVFormat::try_from(vec![resource.clone()])
996 .and_then(|f| f.write_to(Path::new(output_path)))
997 .map_err(|e| {
998 Error::conversion_error(format!("Error writing TSV output: {}", e), None)
999 }),
1000 }
1001 }
1002
1003 pub fn read_file_by_type<P: AsRef<Path>>(
1014 &mut self,
1015 path: P,
1016 format_type: FormatType,
1017 ) -> Result<(), Error> {
1018 self.read_file_by_type_with_options(path, format_type, &ReadOptions::default())
1019 }
1020
1021 pub fn read_file_by_type_with_options<P: AsRef<Path>>(
1023 &mut self,
1024 path: P,
1025 format_type: FormatType,
1026 options: &ReadOptions,
1027 ) -> Result<(), Error> {
1028 let inferred_language = crate::converter::infer_language_from_path(&path, &format_type)?;
1029 let format_language = match &format_type {
1030 FormatType::Strings(lang_opt) | FormatType::AndroidStrings(lang_opt) => {
1031 lang_opt.clone()
1032 }
1033 _ => None,
1034 };
1035
1036 let language = options
1037 .language_hint
1038 .clone()
1039 .or(inferred_language)
1040 .or(format_language);
1041 let requires_language = matches!(
1042 &format_type,
1043 FormatType::Strings(_) | FormatType::AndroidStrings(_)
1044 );
1045 let format_name = format_type.to_string();
1046 let source_path = path.as_ref().to_string_lossy().to_string();
1047
1048 if options.strict && requires_language && language.is_none() {
1049 return Err(Error::missing_language(
1050 source_path.clone(),
1051 format_name.clone(),
1052 ));
1053 }
1054
1055 let domain = path
1056 .as_ref()
1057 .file_stem()
1058 .and_then(|s| s.to_str())
1059 .unwrap_or_default()
1060 .to_string();
1061 let path = path.as_ref();
1062
1063 let mut new_resources = match &format_type {
1064 FormatType::Strings(_) => {
1065 vec![Resource::from(StringsFormat::read_from(path)?)]
1066 }
1067 FormatType::AndroidStrings(_) => {
1068 vec![Resource::from(AndroidStringsFormat::read_from(path)?)]
1069 }
1070 FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(path)?)?,
1071 FormatType::CSV => {
1072 let csv_format = CSVFormat::read_from(path)?;
1074 Vec::<Resource>::try_from(csv_format)?
1075 }
1076 FormatType::TSV => {
1077 let tsv_format = TSVFormat::read_from(path)?;
1079 Vec::<Resource>::try_from(tsv_format)?
1080 }
1081 };
1082
1083 for new_resource in &mut new_resources {
1084 if let Some(ref lang) = language {
1085 new_resource.metadata.language = lang.clone();
1086 }
1087 new_resource.metadata.domain = domain.clone();
1088 new_resource
1089 .metadata
1090 .custom
1091 .insert("format".to_string(), format_name.clone());
1092
1093 if options.attach_provenance {
1094 set_resource_provenance(
1095 new_resource,
1096 &ProvenanceRecord {
1097 source_path: Some(source_path.clone()),
1098 source_format: Some(format_name.clone()),
1099 source_language: language.clone(),
1100 ..ProvenanceRecord::default()
1101 },
1102 );
1103 }
1104 }
1105 self.resources.append(&mut new_resources);
1106
1107 Ok(())
1108 }
1109
1110 pub fn read_file_by_extension<P: AsRef<Path>>(
1122 &mut self,
1123 path: P,
1124 lang: Option<String>,
1125 ) -> Result<(), Error> {
1126 self.read_file_by_extension_with_options(path, &ReadOptions::new().with_language_hint(lang))
1127 }
1128
1129 pub fn read_file_by_extension_with_options<P: AsRef<Path>>(
1131 &mut self,
1132 path: P,
1133 options: &ReadOptions,
1134 ) -> Result<(), Error> {
1135 let format_type = match path.as_ref().extension().and_then(|s| s.to_str()) {
1136 Some("xml") => FormatType::AndroidStrings(options.language_hint.clone()),
1137 Some("strings") => FormatType::Strings(options.language_hint.clone()),
1138 Some("xcstrings") => FormatType::Xcstrings,
1139 Some("csv") => FormatType::CSV,
1140 Some("tsv") => FormatType::TSV,
1141 extension => {
1142 return Err(Error::UnsupportedFormat(format!(
1143 "Unsupported file extension: {:?}.",
1144 extension
1145 )));
1146 }
1147 };
1148
1149 self.read_file_by_type_with_options(path, format_type, options)?;
1150
1151 Ok(())
1152 }
1153
1154 pub fn write_to_file(&self) -> Result<(), Error> {
1161 let mut grouped_resources: std::collections::HashMap<String, Vec<Resource>> =
1163 std::collections::HashMap::new();
1164 for resource in &*self.resources {
1165 let domain = resource.metadata.domain.clone();
1166 grouped_resources
1167 .entry(domain)
1168 .or_default()
1169 .push(resource.clone());
1170 }
1171
1172 for (domain, resources) in grouped_resources {
1174 crate::converter::write_resources_to_file(&resources, &domain)?;
1175 }
1176
1177 Ok(())
1178 }
1179
1180 pub fn cache_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
1189 let path = path.as_ref();
1190 if let Some(parent) = path.parent() {
1191 std::fs::create_dir_all(parent).map_err(Error::Io)?;
1192 }
1193 let mut writer = std::fs::File::create(path).map_err(Error::Io)?;
1194 serde_json::to_writer(&mut writer, &*self.resources).map_err(Error::Parse)?;
1195 Ok(())
1196 }
1197
1198 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
1207 let mut reader = std::fs::File::open(path).map_err(Error::Io)?;
1208 let resources: Vec<Resource> =
1209 serde_json::from_reader(&mut reader).map_err(Error::Parse)?;
1210 Ok(Codec { resources })
1211 }
1212}
1213
1214#[cfg(test)]
1215mod tests {
1216 use super::*;
1217 use crate::types::{Entry, EntryStatus, Metadata, Translation};
1218 use std::collections::HashMap;
1219
1220 #[test]
1221 fn test_builder_pattern() {
1222 let codec = Codec::builder().build();
1224 assert_eq!(codec.resources.len(), 0);
1225
1226 let resource1 = Resource {
1228 metadata: Metadata {
1229 language: "en".to_string(),
1230 domain: "test".to_string(),
1231 custom: std::collections::HashMap::new(),
1232 },
1233 entries: vec![Entry {
1234 id: "hello".to_string(),
1235 value: Translation::Singular("Hello".to_string()),
1236 comment: None,
1237 status: EntryStatus::Translated,
1238 custom: std::collections::HashMap::new(),
1239 }],
1240 };
1241
1242 let resource2 = Resource {
1243 metadata: Metadata {
1244 language: "fr".to_string(),
1245 domain: "test".to_string(),
1246 custom: std::collections::HashMap::new(),
1247 },
1248 entries: vec![Entry {
1249 id: "hello".to_string(),
1250 value: Translation::Singular("Bonjour".to_string()),
1251 comment: None,
1252 status: EntryStatus::Translated,
1253 custom: std::collections::HashMap::new(),
1254 }],
1255 };
1256
1257 let codec = Codec::builder()
1258 .add_resource(resource1.clone())
1259 .add_resource(resource2.clone())
1260 .build();
1261
1262 assert_eq!(codec.resources.len(), 2);
1263 assert_eq!(codec.resources[0].metadata.language, "en");
1264 assert_eq!(codec.resources[1].metadata.language, "fr");
1265 }
1266
1267 #[test]
1268 fn test_builder_validation() {
1269 let resource_without_language = Resource {
1271 metadata: Metadata {
1272 language: "".to_string(),
1273 domain: "test".to_string(),
1274 custom: std::collections::HashMap::new(),
1275 },
1276 entries: vec![],
1277 };
1278
1279 let result = Codec::builder()
1280 .add_resource(resource_without_language)
1281 .build_and_validate();
1282
1283 assert!(result.is_err());
1284 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1285
1286 let resource1 = Resource {
1288 metadata: Metadata {
1289 language: "en".to_string(),
1290 domain: "test".to_string(),
1291 custom: std::collections::HashMap::new(),
1292 },
1293 entries: vec![],
1294 };
1295
1296 let resource2 = Resource {
1297 metadata: Metadata {
1298 language: "en".to_string(), domain: "test".to_string(),
1300 custom: std::collections::HashMap::new(),
1301 },
1302 entries: vec![],
1303 };
1304
1305 let result = Codec::builder()
1306 .add_resource(resource1)
1307 .add_resource(resource2)
1308 .build_and_validate();
1309
1310 assert!(result.is_err());
1311 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1312 }
1313
1314 #[test]
1315 fn test_builder_add_resources() {
1316 let resources = vec![
1317 Resource {
1318 metadata: Metadata {
1319 language: "en".to_string(),
1320 domain: "test".to_string(),
1321 custom: std::collections::HashMap::new(),
1322 },
1323 entries: vec![],
1324 },
1325 Resource {
1326 metadata: Metadata {
1327 language: "fr".to_string(),
1328 domain: "test".to_string(),
1329 custom: std::collections::HashMap::new(),
1330 },
1331 entries: vec![],
1332 },
1333 ];
1334
1335 let codec = Codec::builder().add_resources(resources).build();
1336 assert_eq!(codec.resources.len(), 2);
1337 assert_eq!(codec.resources[0].metadata.language, "en");
1338 assert_eq!(codec.resources[1].metadata.language, "fr");
1339 }
1340
1341 #[test]
1342 fn test_modification_methods() {
1343 use crate::types::{EntryStatus, Translation};
1344
1345 let mut codec = Codec::new();
1347
1348 let resource1 = Resource {
1350 metadata: Metadata {
1351 language: "en".to_string(),
1352 domain: "test".to_string(),
1353 custom: std::collections::HashMap::new(),
1354 },
1355 entries: vec![Entry {
1356 id: "welcome".to_string(),
1357 value: Translation::Singular("Hello".to_string()),
1358 comment: None,
1359 status: EntryStatus::Translated,
1360 custom: std::collections::HashMap::new(),
1361 }],
1362 };
1363
1364 let resource2 = Resource {
1365 metadata: Metadata {
1366 language: "fr".to_string(),
1367 domain: "test".to_string(),
1368 custom: std::collections::HashMap::new(),
1369 },
1370 entries: vec![Entry {
1371 id: "welcome".to_string(),
1372 value: Translation::Singular("Bonjour".to_string()),
1373 comment: None,
1374 status: EntryStatus::Translated,
1375 custom: std::collections::HashMap::new(),
1376 }],
1377 };
1378
1379 codec.add_resource(resource1);
1380 codec.add_resource(resource2);
1381
1382 let entries = codec.find_entries("welcome");
1384 assert_eq!(entries.len(), 2);
1385 assert_eq!(entries[0].0.metadata.language, "en");
1386 assert_eq!(entries[1].0.metadata.language, "fr");
1387
1388 let entry = codec.find_entry("welcome", "en");
1390 assert!(entry.is_some());
1391 assert_eq!(entry.unwrap().id, "welcome");
1392
1393 if let Some(entry) = codec.find_entry_mut("welcome", "en") {
1395 entry.value = Translation::Singular("Hello, World!".to_string());
1396 entry.status = EntryStatus::NeedsReview;
1397 }
1398
1399 let updated_entry = codec.find_entry("welcome", "en").unwrap();
1401 assert_eq!(updated_entry.value.to_string(), "Hello, World!");
1402 assert_eq!(updated_entry.status, EntryStatus::NeedsReview);
1403
1404 codec
1406 .update_translation(
1407 "welcome",
1408 "fr",
1409 Translation::Singular("Bonjour, le monde!".to_string()),
1410 Some(EntryStatus::NeedsReview),
1411 )
1412 .unwrap();
1413
1414 codec
1416 .add_entry(
1417 "new_key",
1418 "en",
1419 Translation::Singular("New message".to_string()),
1420 Some("A new message".to_string()),
1421 Some(EntryStatus::New),
1422 )
1423 .unwrap();
1424
1425 assert!(codec.has_entry("new_key", "en"));
1426 assert_eq!(codec.entry_count("en"), 2);
1427
1428 codec.remove_entry("new_key", "en").unwrap();
1430 assert!(!codec.has_entry("new_key", "en"));
1431 assert_eq!(codec.entry_count("en"), 1);
1432
1433 codec.copy_entry("welcome", "en", "fr", true).unwrap();
1435 let copied_entry = codec.find_entry("welcome", "fr").unwrap();
1436 assert_eq!(copied_entry.status, EntryStatus::New);
1437
1438 let languages: Vec<_> = codec.languages().collect();
1440 assert_eq!(languages.len(), 2);
1441 assert!(languages.contains(&"en"));
1442 assert!(languages.contains(&"fr"));
1443
1444 let keys: Vec<_> = codec.all_keys().collect();
1446 assert_eq!(keys.len(), 1);
1447 assert!(keys.contains(&"welcome"));
1448 }
1449
1450 #[test]
1451 fn test_validation() {
1452 let mut codec = Codec::new();
1453
1454 let resource_without_language = Resource {
1456 metadata: Metadata {
1457 language: "".to_string(),
1458 domain: "test".to_string(),
1459 custom: std::collections::HashMap::new(),
1460 },
1461 entries: vec![],
1462 };
1463
1464 codec.add_resource(resource_without_language);
1465 assert!(codec.validate().is_err());
1466
1467 let mut codec = Codec::new();
1469 let resource1 = Resource {
1470 metadata: Metadata {
1471 language: "en".to_string(),
1472 domain: "test".to_string(),
1473 custom: std::collections::HashMap::new(),
1474 },
1475 entries: vec![],
1476 };
1477
1478 let resource2 = Resource {
1479 metadata: Metadata {
1480 language: "en".to_string(), domain: "test".to_string(),
1482 custom: std::collections::HashMap::new(),
1483 },
1484 entries: vec![],
1485 };
1486
1487 codec.add_resource(resource1);
1488 codec.add_resource(resource2);
1489 assert!(codec.validate().is_err());
1490
1491 let mut codec = Codec::new();
1493 let resource1 = Resource {
1494 metadata: Metadata {
1495 language: "en".to_string(),
1496 domain: "test".to_string(),
1497 custom: std::collections::HashMap::new(),
1498 },
1499 entries: vec![Entry {
1500 id: "welcome".to_string(),
1501 value: Translation::Singular("Hello".to_string()),
1502 comment: None,
1503 status: EntryStatus::Translated,
1504 custom: std::collections::HashMap::new(),
1505 }],
1506 };
1507
1508 let resource2 = Resource {
1509 metadata: Metadata {
1510 language: "fr".to_string(),
1511 domain: "test".to_string(),
1512 custom: std::collections::HashMap::new(),
1513 },
1514 entries: vec![], };
1516
1517 codec.add_resource(resource1);
1518 codec.add_resource(resource2);
1519 assert!(codec.validate().is_err());
1520 }
1521
1522 #[test]
1523 fn test_convert_csv_to_xcstrings() {
1524 let temp_dir = tempfile::tempdir().unwrap();
1526 let input_file = temp_dir.path().join("test.csv");
1527 let output_file = temp_dir.path().join("output.xcstrings");
1528
1529 let csv_content =
1530 "key,en,fr,de\nhello,Hello,Bonjour,Hallo\nbye,Goodbye,Au revoir,Auf Wiedersehen\n";
1531 std::fs::write(&input_file, csv_content).unwrap();
1532
1533 let csv_format = CSVFormat::read_from(&input_file).unwrap();
1535 let resources = Vec::<Resource>::try_from(csv_format).unwrap();
1536 println!("CSV parsed to {} resources:", resources.len());
1537 for (i, resource) in resources.iter().enumerate() {
1538 println!(
1539 " Resource {}: language={}, entries={}",
1540 i,
1541 resource.metadata.language,
1542 resource.entries.len()
1543 );
1544 for entry in &resource.entries {
1545 println!(" Entry: id={}, value={:?}", entry.id, entry.value);
1546 }
1547 }
1548
1549 let result = crate::converter::convert(
1550 &input_file,
1551 FormatType::CSV,
1552 &output_file,
1553 FormatType::Xcstrings,
1554 );
1555
1556 match result {
1557 Ok(()) => println!("✅ CSV to XCStrings conversion succeeded"),
1558 Err(e) => println!("❌ CSV to XCStrings conversion failed: {}", e),
1559 }
1560
1561 if output_file.exists() {
1563 let content = std::fs::read_to_string(&output_file).unwrap();
1564 println!("Output file content: {}", content);
1565 }
1566
1567 let _ = std::fs::remove_file(input_file);
1569 let _ = std::fs::remove_file(output_file);
1570 }
1571
1572 #[test]
1573 fn test_merge_resources_method() {
1574 use crate::types::{ConflictStrategy, Entry, EntryStatus, Metadata, Translation};
1575
1576 let mut codec = Codec::new();
1577
1578 let resource1 = Resource {
1580 metadata: Metadata {
1581 language: "en".to_string(),
1582 domain: "domain1".to_string(),
1583 custom: HashMap::new(),
1584 },
1585 entries: vec![Entry {
1586 id: "hello".to_string(),
1587 value: Translation::Singular("Hello".to_string()),
1588 comment: None,
1589 status: EntryStatus::Translated,
1590 custom: HashMap::new(),
1591 }],
1592 };
1593
1594 let resource2 = Resource {
1595 metadata: Metadata {
1596 language: "en".to_string(),
1597 domain: "domain2".to_string(),
1598 custom: HashMap::new(),
1599 },
1600 entries: vec![Entry {
1601 id: "goodbye".to_string(),
1602 value: Translation::Singular("Goodbye".to_string()),
1603 comment: None,
1604 status: EntryStatus::Translated,
1605 custom: HashMap::new(),
1606 }],
1607 };
1608
1609 let resource3 = Resource {
1610 metadata: Metadata {
1611 language: "en".to_string(),
1612 domain: "domain3".to_string(),
1613 custom: HashMap::new(),
1614 },
1615 entries: vec![Entry {
1616 id: "hello".to_string(), value: Translation::Singular("Hi".to_string()),
1618 comment: None,
1619 status: EntryStatus::Translated,
1620 custom: HashMap::new(),
1621 }],
1622 };
1623
1624 codec.add_resource(resource1);
1626 codec.add_resource(resource2);
1627 codec.add_resource(resource3);
1628
1629 let merges_performed = codec.merge_resources(&ConflictStrategy::Last);
1631 assert_eq!(merges_performed, 1); assert_eq!(codec.resources.len(), 1); let merged_resource = &codec.resources[0];
1635 assert_eq!(merged_resource.metadata.language, "en");
1636 assert_eq!(merged_resource.entries.len(), 2); let hello_entry = merged_resource
1640 .entries
1641 .iter()
1642 .find(|e| e.id == "hello")
1643 .unwrap();
1644 assert_eq!(hello_entry.value.plain_translation_string(), "Hi");
1645 }
1646
1647 #[test]
1648 fn test_merge_resources_method_multiple_languages() {
1649 use crate::types::{ConflictStrategy, Entry, EntryStatus, Metadata, Translation};
1650
1651 let mut codec = Codec::new();
1652
1653 let en_resource1 = Resource {
1655 metadata: Metadata {
1656 language: "en".to_string(),
1657 domain: "domain1".to_string(),
1658 custom: HashMap::new(),
1659 },
1660 entries: vec![Entry {
1661 id: "hello".to_string(),
1662 value: Translation::Singular("Hello".to_string()),
1663 comment: None,
1664 status: EntryStatus::Translated,
1665 custom: HashMap::new(),
1666 }],
1667 };
1668
1669 let en_resource2 = Resource {
1670 metadata: Metadata {
1671 language: "en".to_string(),
1672 domain: "domain2".to_string(),
1673 custom: HashMap::new(),
1674 },
1675 entries: vec![Entry {
1676 id: "goodbye".to_string(),
1677 value: Translation::Singular("Goodbye".to_string()),
1678 comment: None,
1679 status: EntryStatus::Translated,
1680 custom: HashMap::new(),
1681 }],
1682 };
1683
1684 let fr_resource = Resource {
1686 metadata: Metadata {
1687 language: "fr".to_string(),
1688 domain: "domain1".to_string(),
1689 custom: HashMap::new(),
1690 },
1691 entries: vec![Entry {
1692 id: "bonjour".to_string(),
1693 value: Translation::Singular("Bonjour".to_string()),
1694 comment: None,
1695 status: EntryStatus::Translated,
1696 custom: HashMap::new(),
1697 }],
1698 };
1699
1700 codec.add_resource(en_resource1);
1702 codec.add_resource(en_resource2);
1703 codec.add_resource(fr_resource);
1704
1705 let merges_performed = codec.merge_resources(&ConflictStrategy::First);
1707 assert_eq!(merges_performed, 1); assert_eq!(codec.resources.len(), 2); let en_resource = codec.get_by_language("en").unwrap();
1712 assert_eq!(en_resource.entries.len(), 2);
1713
1714 let fr_resource = codec.get_by_language("fr").unwrap();
1716 assert_eq!(fr_resource.entries.len(), 1);
1717 assert_eq!(fr_resource.entries[0].id, "bonjour");
1718 }
1719
1720 #[test]
1721 fn test_merge_resources_method_no_merges() {
1722 use crate::types::{ConflictStrategy, Entry, EntryStatus, Metadata, Translation};
1723
1724 let mut codec = Codec::new();
1725
1726 let en_resource = Resource {
1728 metadata: Metadata {
1729 language: "en".to_string(),
1730 domain: "domain1".to_string(),
1731 custom: HashMap::new(),
1732 },
1733 entries: vec![Entry {
1734 id: "hello".to_string(),
1735 value: Translation::Singular("Hello".to_string()),
1736 comment: None,
1737 status: EntryStatus::Translated,
1738 custom: HashMap::new(),
1739 }],
1740 };
1741
1742 let fr_resource = Resource {
1743 metadata: Metadata {
1744 language: "fr".to_string(),
1745 domain: "domain1".to_string(),
1746 custom: HashMap::new(),
1747 },
1748 entries: vec![Entry {
1749 id: "bonjour".to_string(),
1750 value: Translation::Singular("Bonjour".to_string()),
1751 comment: None,
1752 status: EntryStatus::Translated,
1753 custom: HashMap::new(),
1754 }],
1755 };
1756
1757 codec.add_resource(en_resource);
1759 codec.add_resource(fr_resource);
1760
1761 let merges_performed = codec.merge_resources(&ConflictStrategy::Last);
1763 assert_eq!(merges_performed, 0); assert_eq!(codec.resources.len(), 2); assert!(codec.get_by_language("en").is_some());
1768 assert!(codec.get_by_language("fr").is_some());
1769 }
1770
1771 #[test]
1772 fn test_merge_resources_method_empty_codec() {
1773 let mut codec = Codec::new();
1774
1775 let merges_performed = codec.merge_resources(&ConflictStrategy::Last);
1777 assert_eq!(merges_performed, 0);
1778 assert_eq!(codec.resources.len(), 0);
1779 }
1780
1781 #[test]
1782 fn test_extend_from_and_from_codecs() {
1783 let mut codec1 = Codec::new();
1784 let mut codec2 = Codec::new();
1785
1786 let en_resource = Resource {
1787 metadata: Metadata {
1788 language: "en".to_string(),
1789 domain: "d1".to_string(),
1790 custom: HashMap::new(),
1791 },
1792 entries: vec![Entry {
1793 id: "hello".to_string(),
1794 value: Translation::Singular("Hello".to_string()),
1795 comment: None,
1796 status: EntryStatus::Translated,
1797 custom: HashMap::new(),
1798 }],
1799 };
1800
1801 let fr_resource = Resource {
1802 metadata: Metadata {
1803 language: "fr".to_string(),
1804 domain: "d2".to_string(),
1805 custom: HashMap::new(),
1806 },
1807 entries: vec![Entry {
1808 id: "bonjour".to_string(),
1809 value: Translation::Singular("Bonjour".to_string()),
1810 comment: None,
1811 status: EntryStatus::Translated,
1812 custom: HashMap::new(),
1813 }],
1814 };
1815
1816 codec1.add_resource(en_resource);
1817 codec2.add_resource(fr_resource);
1818
1819 let mut combined = codec1;
1821 combined.extend_from(codec2);
1822 assert_eq!(combined.resources.len(), 2);
1823
1824 let c = Codec::from_codecs(vec![combined.clone()]);
1826 assert_eq!(c.resources.len(), 2);
1827 }
1828
1829 #[test]
1830 fn test_merge_codecs_across_instances() {
1831 use crate::types::ConflictStrategy;
1832
1833 let mut c1 = Codec::new();
1835 let mut c2 = Codec::new();
1836
1837 c1.add_resource(Resource {
1838 metadata: Metadata {
1839 language: "en".to_string(),
1840 domain: "d1".to_string(),
1841 custom: HashMap::new(),
1842 },
1843 entries: vec![Entry {
1844 id: "hello".to_string(),
1845 value: Translation::Singular("Hello".to_string()),
1846 comment: None,
1847 status: EntryStatus::Translated,
1848 custom: HashMap::new(),
1849 }],
1850 });
1851
1852 c2.add_resource(Resource {
1853 metadata: Metadata {
1854 language: "en".to_string(),
1855 domain: "d2".to_string(),
1856 custom: HashMap::new(),
1857 },
1858 entries: vec![Entry {
1859 id: "goodbye".to_string(),
1860 value: Translation::Singular("Goodbye".to_string()),
1861 comment: None,
1862 status: EntryStatus::Translated,
1863 custom: HashMap::new(),
1864 }],
1865 });
1866
1867 let merged = Codec::merge_codecs(vec![c1, c2], &ConflictStrategy::Last);
1868 assert_eq!(merged.resources.len(), 1);
1869 assert_eq!(merged.resources[0].metadata.language, "en");
1870 assert_eq!(merged.resources[0].entries.len(), 2);
1871 }
1872
1873 #[test]
1874 fn test_validate_placeholders_across_languages() {
1875 let mut codec = Codec::new();
1876 codec.add_resource(Resource {
1878 metadata: Metadata {
1879 language: "en".into(),
1880 domain: "d".into(),
1881 custom: HashMap::new(),
1882 },
1883 entries: vec![Entry {
1884 id: "greet".into(),
1885 value: Translation::Singular("Hello %1$@".into()),
1886 comment: None,
1887 status: EntryStatus::Translated,
1888 custom: HashMap::new(),
1889 }],
1890 });
1891 codec.add_resource(Resource {
1892 metadata: Metadata {
1893 language: "fr".into(),
1894 domain: "d".into(),
1895 custom: HashMap::new(),
1896 },
1897 entries: vec![Entry {
1898 id: "greet".into(),
1899 value: Translation::Singular("Bonjour %1$s".into()),
1900 comment: None,
1901 status: EntryStatus::Translated,
1902 custom: HashMap::new(),
1903 }],
1904 });
1905 assert!(codec.validate_placeholders(true).is_ok());
1906 }
1907
1908 #[test]
1909 fn test_validate_placeholders_mismatch() {
1910 let mut codec = Codec::new();
1911 codec.add_resource(Resource {
1912 metadata: Metadata {
1913 language: "en".into(),
1914 domain: "d".into(),
1915 custom: HashMap::new(),
1916 },
1917 entries: vec![Entry {
1918 id: "count".into(),
1919 value: Translation::Singular("%d files".into()),
1920 comment: None,
1921 status: EntryStatus::Translated,
1922 custom: HashMap::new(),
1923 }],
1924 });
1925 codec.add_resource(Resource {
1926 metadata: Metadata {
1927 language: "fr".into(),
1928 domain: "d".into(),
1929 custom: HashMap::new(),
1930 },
1931 entries: vec![Entry {
1932 id: "count".into(),
1933 value: Translation::Singular("%s fichiers".into()),
1934 comment: None,
1935 status: EntryStatus::Translated,
1936 custom: HashMap::new(),
1937 }],
1938 });
1939 assert!(codec.validate_placeholders(true).is_err());
1940 }
1941
1942 #[test]
1943 fn test_collect_placeholder_issues_non_strict_ok() {
1944 let mut codec = Codec::new();
1945 codec.add_resource(Resource {
1946 metadata: Metadata {
1947 language: "en".into(),
1948 domain: "d".into(),
1949 custom: HashMap::new(),
1950 },
1951 entries: vec![Entry {
1952 id: "count".into(),
1953 value: Translation::Singular("%d files".into()),
1954 comment: None,
1955 status: EntryStatus::Translated,
1956 custom: HashMap::new(),
1957 }],
1958 });
1959 codec.add_resource(Resource {
1960 metadata: Metadata {
1961 language: "fr".into(),
1962 domain: "d".into(),
1963 custom: HashMap::new(),
1964 },
1965 entries: vec![Entry {
1966 id: "count".into(),
1967 value: Translation::Singular("%s fichiers".into()),
1968 comment: None,
1969 status: EntryStatus::Translated,
1970 custom: HashMap::new(),
1971 }],
1972 });
1973 assert!(codec.validate_placeholders(false).is_ok());
1975 let issues = codec.collect_placeholder_issues();
1976 assert!(!issues.is_empty());
1977 }
1978
1979 #[test]
1980 fn test_normalize_placeholders_in_place() {
1981 let mut codec = Codec::new();
1982 codec.add_resource(Resource {
1983 metadata: Metadata {
1984 language: "en".into(),
1985 domain: "d".into(),
1986 custom: HashMap::new(),
1987 },
1988 entries: vec![Entry {
1989 id: "g".into(),
1990 value: Translation::Singular("Hello %@ and %1$@".into()),
1991 comment: None,
1992 status: EntryStatus::Translated,
1993 custom: HashMap::new(),
1994 }],
1995 });
1996 codec.normalize_placeholders_in_place();
1997 let v = match &codec.resources[0].entries[0].value {
1998 Translation::Singular(v) => v.clone(),
1999 _ => String::new(),
2000 };
2001 assert!(v.contains("%s"));
2002 assert!(v.contains("%1$s"));
2003 }
2004
2005 #[test]
2006 fn test_read_file_by_type_with_strict_requires_language() {
2007 let temp = tempfile::tempdir().unwrap();
2008 let input = temp.path().join("Localizable.strings");
2009 std::fs::write(&input, "\"hello\" = \"Hello\";").unwrap();
2010
2011 let mut codec = Codec::new();
2012 let err = codec
2013 .read_file_by_type_with_options(
2014 &input,
2015 FormatType::Strings(None),
2016 &ReadOptions::new().with_strict(true),
2017 )
2018 .unwrap_err();
2019 assert_eq!(err.error_code(), crate::ErrorCode::MissingLanguage);
2020 }
2021
2022 #[test]
2023 fn test_read_file_with_provenance() {
2024 let temp = tempfile::tempdir().unwrap();
2025 let lproj = temp.path().join("en.lproj");
2026 std::fs::create_dir_all(&lproj).unwrap();
2027 let input = lproj.join("Localizable.strings");
2028 std::fs::write(&input, "\"hello\" = \"Hello\";").unwrap();
2029
2030 let mut codec = Codec::new();
2031 codec
2032 .read_file_by_extension_with_options(&input, &ReadOptions::new().with_provenance(true))
2033 .unwrap();
2034
2035 let provenance = crate::resource_provenance(codec.resources.first().unwrap()).unwrap();
2036 assert_eq!(
2037 provenance.source_path,
2038 Some(input.to_string_lossy().to_string())
2039 );
2040 assert_eq!(provenance.source_format, Some("strings".to_string()));
2041 }
2042}