langcodec/codec.rs
1/// This module provides the `Codec` struct and associated functionality for reading,
2/// writing, caching, and loading localized resource files in various formats.
3/// The `Codec` struct manages a collection of `Resource` instances and supports
4/// format inference, language detection from file paths, and serialization.
5///
6/// The module handles different localization file formats such as Apple `.strings`,
7/// Android XML strings, and `.xcstrings`, providing methods to read from files by type
8/// or extension, write resources back to files, and cache resources to JSON.
9///
10use crate::formats::{CSVFormat, TSVFormat};
11use crate::{
12 error::Error,
13 formats::*,
14 traits::Parser,
15 types::{Entry, Resource},
16};
17use std::path::Path;
18
19/// Represents a collection of localized resources and provides methods to read,
20/// write, cache, and load these resources.
21#[derive(Debug)]
22pub struct Codec {
23 /// The collection of resources managed by this codec.
24 pub resources: Vec<Resource>,
25}
26
27impl Default for Codec {
28 fn default() -> Self {
29 Codec::new()
30 }
31}
32
33impl Codec {
34 /// Creates a new, empty `Codec`.
35 ///
36 /// # Returns
37 ///
38 /// A new `Codec` instance with no resources.
39 pub fn new() -> Self {
40 Codec {
41 resources: Vec::new(),
42 }
43 }
44
45 /// Creates a new `CodecBuilder` for fluent construction.
46 ///
47 /// This method returns a builder that allows you to chain method calls
48 /// to add resources from files and then build the final `Codec` instance.
49 ///
50 /// # Example
51 ///
52 /// ```rust,no_run
53 /// use langcodec::Codec;
54 ///
55 /// let codec = Codec::builder()
56 /// .add_file("en.strings")?
57 /// .add_file("fr.strings")?
58 /// .build();
59 /// # Ok::<(), langcodec::Error>(())
60 /// ```
61 ///
62 /// # Returns
63 ///
64 /// Returns a new `CodecBuilder` instance.
65 pub fn builder() -> crate::builder::CodecBuilder {
66 crate::builder::CodecBuilder::new()
67 }
68
69 /// Returns an iterator over all resources.
70 pub fn iter(&self) -> std::slice::Iter<Resource> {
71 self.resources.iter()
72 }
73
74 /// Returns a mutable iterator over all resources.
75 pub fn iter_mut(&mut self) -> std::slice::IterMut<Resource> {
76 self.resources.iter_mut()
77 }
78
79 /// Finds a resource by its language code, if present.
80 pub fn get_by_language(&self, lang: &str) -> Option<&Resource> {
81 self.resources
82 .iter()
83 .find(|res| res.metadata.language == lang)
84 }
85
86 /// Finds a mutable resource by its language code, if present.
87 pub fn get_mut_by_language(&mut self, lang: &str) -> Option<&mut Resource> {
88 self.resources
89 .iter_mut()
90 .find(|res| res.metadata.language == lang)
91 }
92
93 /// Adds a new resource to the collection.
94 pub fn add_resource(&mut self, resource: Resource) {
95 self.resources.push(resource);
96 }
97
98 // ===== HIGH-LEVEL MODIFICATION METHODS =====
99
100 /// Finds an entry by its key across all languages.
101 ///
102 /// Returns an iterator over all resources and their matching entries.
103 ///
104 /// # Arguments
105 ///
106 /// * `key` - The entry key to search for
107 ///
108 /// # Returns
109 ///
110 /// An iterator yielding `(&Resource, &Entry)` pairs for all matching entries.
111 ///
112 /// # Example
113 ///
114 /// ```rust
115 /// use langcodec::Codec;
116 ///
117 /// let mut codec = Codec::new();
118 /// // ... load resources ...
119 ///
120 /// for (resource, entry) in codec.find_entries("welcome_message") {
121 /// println!("{}: {}", resource.metadata.language, entry.value);
122 /// }
123 /// ```
124 pub fn find_entries(&self, key: &str) -> Vec<(&Resource, &Entry)> {
125 let mut results = Vec::new();
126 for resource in &self.resources {
127 for entry in &resource.entries {
128 if entry.id == key {
129 results.push((resource, entry));
130 }
131 }
132 }
133 results
134 }
135
136 /// Finds an entry by its key in a specific language.
137 ///
138 /// # Arguments
139 ///
140 /// * `key` - The entry key to search for
141 /// * `language` - The language code (e.g., "en", "fr")
142 ///
143 /// # Returns
144 ///
145 /// `Some(&Entry)` if found, `None` otherwise.
146 ///
147 /// # Example
148 ///
149 /// ```rust
150 /// use langcodec::Codec;
151 ///
152 /// let mut codec = Codec::new();
153 /// // ... load resources ...
154 ///
155 /// if let Some(entry) = codec.find_entry("welcome_message", "en") {
156 /// println!("English welcome: {}", entry.value);
157 /// }
158 /// ```
159 pub fn find_entry(&self, key: &str, language: &str) -> Option<&Entry> {
160 self.get_by_language(language)?
161 .entries
162 .iter()
163 .find(|entry| entry.id == key)
164 }
165
166 /// Finds a mutable entry by its key in a specific language.
167 ///
168 /// # Arguments
169 ///
170 /// * `key` - The entry key to search for
171 /// * `language` - The language code (e.g., "en", "fr")
172 ///
173 /// # Returns
174 ///
175 /// `Some(&mut Entry)` if found, `None` otherwise.
176 ///
177 /// # Example
178 ///
179 /// ```rust
180 /// use langcodec::Codec;
181 /// use langcodec::types::Translation;
182 ///
183 /// let mut codec = Codec::new();
184 /// // ... load resources ...
185 ///
186 /// if let Some(entry) = codec.find_entry_mut("welcome_message", "en") {
187 /// entry.value = Translation::Singular("Hello, World!".to_string());
188 /// entry.status = langcodec::types::EntryStatus::Translated;
189 /// }
190 /// ```
191 pub fn find_entry_mut(&mut self, key: &str, language: &str) -> Option<&mut Entry> {
192 self.get_mut_by_language(language)?
193 .entries
194 .iter_mut()
195 .find(|entry| entry.id == key)
196 }
197
198 /// Updates a translation for a specific key and language.
199 ///
200 /// # Arguments
201 ///
202 /// * `key` - The entry key to update
203 /// * `language` - The language code (e.g., "en", "fr")
204 /// * `translation` - The new translation value
205 /// * `status` - Optional new status (defaults to `Translated`)
206 ///
207 /// # Returns
208 ///
209 /// `Ok(())` if the entry was found and updated, `Err` if not found.
210 ///
211 /// # Example
212 ///
213 /// ```rust
214 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
215 ///
216 /// let mut codec = Codec::new();
217 /// // Add an entry first
218 /// codec.add_entry("welcome", "en", Translation::Singular("Hello".to_string()), None, None)?;
219 ///
220 /// codec.update_translation(
221 /// "welcome",
222 /// "en",
223 /// Translation::Singular("Hello, World!".to_string()),
224 /// Some(EntryStatus::Translated)
225 /// )?;
226 /// # Ok::<(), langcodec::Error>(())
227 /// ```
228 pub fn update_translation(
229 &mut self,
230 key: &str,
231 language: &str,
232 translation: crate::types::Translation,
233 status: Option<crate::types::EntryStatus>,
234 ) -> Result<(), Error> {
235 if let Some(entry) = self.find_entry_mut(key, language) {
236 entry.value = translation;
237 if let Some(new_status) = status {
238 entry.status = new_status;
239 }
240 Ok(())
241 } else {
242 Err(Error::InvalidResource(format!(
243 "Entry '{}' not found in language '{}'",
244 key, language
245 )))
246 }
247 }
248
249 /// Adds a new entry to a specific language.
250 ///
251 /// If the language doesn't exist, it will be created automatically.
252 ///
253 /// # Arguments
254 ///
255 /// * `key` - The entry key
256 /// * `language` - The language code (e.g., "en", "fr")
257 /// * `translation` - The translation value
258 /// * `comment` - Optional comment for translators
259 /// * `status` - Optional status (defaults to `New`)
260 ///
261 /// # Returns
262 ///
263 /// `Ok(())` if the entry was added successfully.
264 ///
265 /// # Example
266 ///
267 /// ```rust
268 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
269 ///
270 /// let mut codec = Codec::new();
271 ///
272 /// codec.add_entry(
273 /// "new_message",
274 /// "en",
275 /// Translation::Singular("This is a new message".to_string()),
276 /// Some("This is a new message for users".to_string()),
277 /// Some(EntryStatus::New)
278 /// )?;
279 /// # Ok::<(), langcodec::Error>(())
280 /// ```
281 pub fn add_entry(
282 &mut self,
283 key: &str,
284 language: &str,
285 translation: crate::types::Translation,
286 comment: Option<String>,
287 status: Option<crate::types::EntryStatus>,
288 ) -> Result<(), Error> {
289 // Find or create the resource for this language
290 let resource = if let Some(resource) = self.get_mut_by_language(language) {
291 resource
292 } else {
293 // Create a new resource for this language
294 let new_resource = crate::types::Resource {
295 metadata: crate::types::Metadata {
296 language: language.to_string(),
297 domain: "".to_string(),
298 custom: std::collections::HashMap::new(),
299 },
300 entries: Vec::new(),
301 };
302 self.add_resource(new_resource);
303 self.get_mut_by_language(language).unwrap()
304 };
305
306 let entry = crate::types::Entry {
307 id: key.to_string(),
308 value: translation,
309 comment,
310 status: status.unwrap_or(crate::types::EntryStatus::New),
311 custom: std::collections::HashMap::new(),
312 };
313 resource.add_entry(entry);
314 Ok(())
315 }
316
317 /// Removes an entry from a specific language.
318 ///
319 /// # Arguments
320 ///
321 /// * `key` - The entry key to remove
322 /// * `language` - The language code (e.g., "en", "fr")
323 ///
324 /// # Returns
325 ///
326 /// `Ok(())` if the entry was found and removed, `Err` if not found.
327 ///
328 /// # Example
329 ///
330 /// ```rust
331 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
332 ///
333 /// let mut codec = Codec::new();
334 /// // Add a resource first
335 /// codec.add_entry("test_key", "en", Translation::Singular("Test".to_string()), None, None)?;
336 ///
337 /// // Now remove it
338 /// codec.remove_entry("test_key", "en")?;
339 /// # Ok::<(), langcodec::Error>(())
340 /// ```
341 pub fn remove_entry(&mut self, key: &str, language: &str) -> Result<(), Error> {
342 if let Some(resource) = self.get_mut_by_language(language) {
343 let initial_len = resource.entries.len();
344 resource.entries.retain(|entry| entry.id != key);
345
346 if resource.entries.len() == initial_len {
347 Err(Error::InvalidResource(format!(
348 "Entry '{}' not found in language '{}'",
349 key, language
350 )))
351 } else {
352 Ok(())
353 }
354 } else {
355 Err(Error::InvalidResource(format!(
356 "Language '{}' not found",
357 language
358 )))
359 }
360 }
361
362 /// Copies an entry from one language to another.
363 ///
364 /// This is useful for creating new translations based on existing ones.
365 ///
366 /// # Arguments
367 ///
368 /// * `key` - The entry key to copy
369 /// * `from_language` - The source language
370 /// * `to_language` - The target language
371 /// * `update_status` - Whether to update the status to `New` in the target language
372 ///
373 /// # Returns
374 ///
375 /// `Ok(())` if the entry was copied successfully, `Err` if not found.
376 ///
377 /// # Example
378 ///
379 /// ```rust
380 /// use langcodec::{Codec, types::{Translation, EntryStatus}};
381 ///
382 /// let mut codec = Codec::new();
383 /// // Add source entry first
384 /// codec.add_entry("welcome", "en", Translation::Singular("Hello".to_string()), None, None)?;
385 ///
386 /// // Copy English entry to French as a starting point
387 /// codec.copy_entry("welcome", "en", "fr", true)?;
388 /// # Ok::<(), langcodec::Error>(())
389 /// ```
390 pub fn copy_entry(
391 &mut self,
392 key: &str,
393 from_language: &str,
394 to_language: &str,
395 update_status: bool,
396 ) -> Result<(), Error> {
397 let source_entry = self.find_entry(key, from_language).ok_or_else(|| {
398 Error::InvalidResource(format!(
399 "Entry '{}' not found in source language '{}'",
400 key, from_language
401 ))
402 })?;
403
404 let mut new_entry = source_entry.clone();
405 if update_status {
406 new_entry.status = crate::types::EntryStatus::New;
407 }
408
409 // Find or create the target resource
410 let target_resource = if let Some(resource) = self.get_mut_by_language(to_language) {
411 resource
412 } else {
413 // Create a new resource for the target language
414 let new_resource = crate::types::Resource {
415 metadata: crate::types::Metadata {
416 language: to_language.to_string(),
417 domain: "".to_string(),
418 custom: std::collections::HashMap::new(),
419 },
420 entries: Vec::new(),
421 };
422 self.add_resource(new_resource);
423 self.get_mut_by_language(to_language).unwrap()
424 };
425
426 // Remove existing entry if it exists
427 target_resource.entries.retain(|entry| entry.id != key);
428 target_resource.add_entry(new_entry);
429 Ok(())
430 }
431
432 /// Gets all languages available in the codec.
433 ///
434 /// # Returns
435 ///
436 /// An iterator over all language codes.
437 ///
438 /// # Example
439 ///
440 /// ```rust
441 /// use langcodec::Codec;
442 ///
443 /// let codec = Codec::new();
444 /// // ... load resources ...
445 ///
446 /// for language in codec.languages() {
447 /// println!("Available language: {}", language);
448 /// }
449 /// ```
450 pub fn languages(&self) -> impl Iterator<Item = &str> {
451 self.resources.iter().map(|r| r.metadata.language.as_str())
452 }
453
454 /// Gets all unique entry keys across all languages.
455 ///
456 /// # Returns
457 ///
458 /// An iterator over all unique entry keys.
459 ///
460 /// # Example
461 ///
462 /// ```rust
463 /// use langcodec::Codec;
464 ///
465 /// let codec = Codec::new();
466 /// // ... load resources ...
467 ///
468 /// for key in codec.all_keys() {
469 /// println!("Available key: {}", key);
470 /// }
471 /// ```
472 pub fn all_keys(&self) -> impl Iterator<Item = &str> {
473 use std::collections::HashSet;
474
475 let mut keys = HashSet::new();
476 for resource in &self.resources {
477 for entry in &resource.entries {
478 keys.insert(entry.id.as_str());
479 }
480 }
481 keys.into_iter()
482 }
483
484 /// Checks if an entry exists in a specific language.
485 ///
486 /// # Arguments
487 ///
488 /// * `key` - The entry key to check
489 /// * `language` - The language code (e.g., "en", "fr")
490 ///
491 /// # Returns
492 ///
493 /// `true` if the entry exists, `false` otherwise.
494 ///
495 /// # Example
496 ///
497 /// ```rust
498 /// use langcodec::Codec;
499 ///
500 /// let codec = Codec::new();
501 /// // ... load resources ...
502 ///
503 /// if codec.has_entry("welcome_message", "en") {
504 /// println!("English welcome message exists");
505 /// }
506 /// ```
507 pub fn has_entry(&self, key: &str, language: &str) -> bool {
508 self.find_entry(key, language).is_some()
509 }
510
511 /// Gets the count of entries in a specific language.
512 ///
513 /// # Arguments
514 ///
515 /// * `language` - The language code (e.g., "en", "fr")
516 ///
517 /// # Returns
518 ///
519 /// The number of entries in the specified language, or 0 if the language doesn't exist.
520 ///
521 /// # Example
522 ///
523 /// ```rust
524 /// use langcodec::Codec;
525 ///
526 /// let codec = Codec::new();
527 /// // ... load resources ...
528 ///
529 /// let count = codec.entry_count("en");
530 /// println!("English has {} entries", count);
531 /// ```
532 pub fn entry_count(&self, language: &str) -> usize {
533 self.get_by_language(language)
534 .map(|r| r.entries.len())
535 .unwrap_or(0)
536 }
537
538 /// Validates the codec for common issues.
539 ///
540 /// # Returns
541 ///
542 /// `Ok(())` if validation passes, `Err(Error)` with details if validation fails.
543 ///
544 /// # Example
545 ///
546 /// ```rust
547 /// use langcodec::Codec;
548 ///
549 /// let mut codec = Codec::new();
550 /// // ... add resources ...
551 ///
552 /// if let Err(validation_error) = codec.validate() {
553 /// eprintln!("Validation failed: {}", validation_error);
554 /// }
555 /// ```
556 pub fn validate(&self) -> Result<(), Error> {
557 // Check for empty resources
558 if self.resources.is_empty() {
559 return Err(Error::InvalidResource("No resources found".to_string()));
560 }
561
562 // Check for duplicate languages
563 let mut languages = std::collections::HashSet::new();
564 for resource in &self.resources {
565 if !languages.insert(&resource.metadata.language) {
566 return Err(Error::InvalidResource(format!(
567 "Duplicate language found: {}",
568 resource.metadata.language
569 )));
570 }
571 }
572
573 // Check for empty resources
574 for resource in &self.resources {
575 if resource.entries.is_empty() {
576 return Err(Error::InvalidResource(format!(
577 "Resource for language '{}' has no entries",
578 resource.metadata.language
579 )));
580 }
581 }
582
583 Ok(())
584 }
585
586 /// Merges multiple resources into a single resource with conflict resolution.
587 ///
588 /// This function merges resources that all have the same language.
589 /// Only entries with the same ID are treated as conflicts.
590 ///
591 /// # Arguments
592 ///
593 /// * `resources` - The resources to merge (must all have the same language)
594 /// * `conflict_strategy` - How to handle conflicting entries (same ID)
595 ///
596 /// # Returns
597 ///
598 /// A merged resource with all entries from the input resources.
599 ///
600 /// # Errors
601 ///
602 /// Returns an error if:
603 /// - No resources are provided
604 /// - Resources have different languages (each Resource represents one language)
605 ///
606 /// # Example
607 ///
608 /// ```rust
609 /// use langcodec::{Codec, types::{Resource, Metadata, Entry, Translation, EntryStatus}};
610 ///
611 /// let mut codec = Codec::new();
612 /// // ... load resources ...
613 ///
614 /// // Create some sample resources for merging
615 /// let resource1 = Resource {
616 /// metadata: Metadata {
617 /// language: "en".to_string(),
618 /// domain: "domain".to_string(),
619 /// custom: std::collections::HashMap::new(),
620 /// },
621 /// entries: vec![
622 /// Entry {
623 /// id: "hello".to_string(),
624 /// value: Translation::Singular("Hello".to_string()),
625 /// comment: None,
626 /// status: EntryStatus::Translated,
627 /// custom: std::collections::HashMap::new(),
628 /// }
629 /// ],
630 /// };
631 ///
632 /// let merged = Codec::merge_resources(
633 /// &[resource1],
634 /// langcodec::types::ConflictStrategy::Last
635 /// )?;
636 /// # Ok::<(), langcodec::Error>(())
637 /// ```
638 pub fn merge_resources(
639 resources: &[Resource],
640 conflict_strategy: crate::types::ConflictStrategy,
641 ) -> Result<Resource, Error> {
642 if resources.is_empty() {
643 return Err(Error::InvalidResource("No resources to merge".to_string()));
644 }
645
646 // Validate that all resources have the same language
647 let first_language = &resources[0].metadata.language;
648 for (i, resource) in resources.iter().enumerate() {
649 if resource.metadata.language != *first_language {
650 return Err(Error::InvalidResource(format!(
651 "Cannot merge resources with different languages: resource {} has language '{}', but first resource has language '{}'",
652 i + 1,
653 resource.metadata.language,
654 first_language
655 )));
656 }
657 }
658
659 let mut merged = resources[0].clone();
660 let mut all_entries = std::collections::HashMap::new();
661
662 // Collect all entries from all resources
663 for resource in resources {
664 for entry in &resource.entries {
665 // Use the original entry ID for conflict resolution
666 // Since all resources have the same language, conflicts are based on ID only
667 match conflict_strategy {
668 crate::types::ConflictStrategy::First => {
669 all_entries
670 .entry(&entry.id)
671 .or_insert_with(|| entry.clone());
672 }
673 crate::types::ConflictStrategy::Last => {
674 all_entries.insert(&entry.id, entry.clone());
675 }
676 crate::types::ConflictStrategy::Skip => {
677 if all_entries.contains_key(&entry.id) {
678 // Remove the existing entry and skip this one too
679 all_entries.remove(&entry.id);
680 continue;
681 }
682 all_entries.insert(&entry.id, entry.clone());
683 }
684 }
685 }
686 }
687
688 // Convert back to vector and sort by key for consistent output
689 merged.entries = all_entries.into_values().collect();
690 merged.entries.sort_by(|a, b| a.id.cmp(&b.id));
691
692 Ok(merged)
693 }
694
695 /// Writes a resource to a file with automatic format detection.
696 ///
697 /// # Arguments
698 ///
699 /// * `resource` - The resource to write
700 /// * `output_path` - The output file path
701 ///
702 /// # Returns
703 ///
704 /// `Ok(())` on success, `Err(Error)` on failure.
705 ///
706 /// # Example
707 ///
708 /// ```rust,no_run
709 /// use langcodec::{Codec, types::{Resource, Metadata, Entry, Translation, EntryStatus}};
710 ///
711 /// let resource = Resource {
712 /// metadata: Metadata {
713 /// language: "en".to_string(),
714 /// domain: "domain".to_string(),
715 /// custom: std::collections::HashMap::new(),
716 /// },
717 /// entries: vec![],
718 /// };
719 /// Codec::write_resource_to_file(&resource, "output.strings")?;
720 /// # Ok::<(), langcodec::Error>(())
721 /// ```
722 pub fn write_resource_to_file(resource: &Resource, output_path: &str) -> Result<(), Error> {
723 use crate::formats::{
724 AndroidStringsFormat, CSVFormat, StringsFormat, TSVFormat, XcstringsFormat,
725 };
726 use std::path::Path;
727
728 // Infer format from output path
729 let format_type = infer_format_from_extension(output_path).ok_or_else(|| {
730 Error::InvalidResource(format!(
731 "Cannot infer format from output path: {}",
732 output_path
733 ))
734 })?;
735
736 match format_type {
737 crate::formats::FormatType::AndroidStrings(_) => {
738 AndroidStringsFormat::from(resource.clone())
739 .write_to(Path::new(output_path))
740 .map_err(|e| {
741 Error::conversion_error(
742 format!("Error writing AndroidStrings output: {}", e),
743 None,
744 )
745 })
746 }
747 crate::formats::FormatType::Strings(_) => StringsFormat::try_from(resource.clone())
748 .and_then(|f| f.write_to(Path::new(output_path)))
749 .map_err(|e| {
750 Error::conversion_error(format!("Error writing Strings output: {}", e), None)
751 }),
752 crate::formats::FormatType::Xcstrings => {
753 XcstringsFormat::try_from(vec![resource.clone()])
754 .and_then(|f| f.write_to(Path::new(output_path)))
755 .map_err(|e| {
756 Error::conversion_error(
757 format!("Error writing Xcstrings output: {}", e),
758 None,
759 )
760 })
761 }
762 crate::formats::FormatType::CSV => CSVFormat::try_from(vec![resource.clone()])
763 .and_then(|f| f.write_to(Path::new(output_path)))
764 .map_err(|e| {
765 Error::conversion_error(format!("Error writing CSV output: {}", e), None)
766 }),
767 crate::formats::FormatType::TSV => TSVFormat::try_from(vec![resource.clone()])
768 .and_then(|f| f.write_to(Path::new(output_path)))
769 .map_err(|e| {
770 Error::conversion_error(format!("Error writing TSV output: {}", e), None)
771 }),
772 }
773 }
774
775 /// Converts a vector of resources to a specific output format.
776 ///
777 /// # Arguments
778 ///
779 /// * `resources` - The resources to convert
780 /// * `output_path` - The output file path
781 /// * `output_format` - The target format
782 ///
783 /// # Returns
784 ///
785 /// `Ok(())` on success, `Err(Error)` on failure.
786 ///
787 /// # Example
788 ///
789 /// ```rust, no_run
790 /// use langcodec::{Codec, types::{Resource, Metadata, Entry, Translation, EntryStatus}, formats::FormatType};
791 ///
792 /// let resources = vec![Resource {
793 /// metadata: Metadata {
794 /// language: "en".to_string(),
795 /// domain: "domain".to_string(),
796 /// custom: std::collections::HashMap::new(),
797 /// },
798 /// entries: vec![],
799 /// }];
800 /// Codec::convert_resources_to_format(
801 /// resources,
802 /// "output.strings",
803 /// FormatType::Strings(None)
804 /// )?;
805 /// # Ok::<(), langcodec::Error>(())
806 /// ```
807 pub fn convert_resources_to_format(
808 resources: Vec<Resource>,
809 output_path: &str,
810 output_format: crate::formats::FormatType,
811 ) -> Result<(), Error> {
812 use crate::formats::{
813 AndroidStringsFormat, CSVFormat, StringsFormat, TSVFormat, XcstringsFormat,
814 };
815 use std::path::Path;
816
817 match output_format {
818 crate::formats::FormatType::AndroidStrings(_) => {
819 if let Some(resource) = resources.first() {
820 AndroidStringsFormat::from(resource.clone())
821 .write_to(Path::new(output_path))
822 .map_err(|e| {
823 Error::conversion_error(
824 format!("Error writing AndroidStrings output: {}", e),
825 None,
826 )
827 })
828 } else {
829 Err(Error::InvalidResource(
830 "No resources to convert".to_string(),
831 ))
832 }
833 }
834 crate::formats::FormatType::Strings(_) => {
835 if let Some(resource) = resources.first() {
836 StringsFormat::try_from(resource.clone())
837 .and_then(|f| f.write_to(Path::new(output_path)))
838 .map_err(|e| {
839 Error::conversion_error(
840 format!("Error writing Strings output: {}", e),
841 None,
842 )
843 })
844 } else {
845 Err(Error::InvalidResource(
846 "No resources to convert".to_string(),
847 ))
848 }
849 }
850 crate::formats::FormatType::Xcstrings => XcstringsFormat::try_from(resources)
851 .and_then(|f| f.write_to(Path::new(output_path)))
852 .map_err(|e| {
853 Error::conversion_error(format!("Error writing Xcstrings output: {}", e), None)
854 }),
855 crate::formats::FormatType::CSV => {
856 if let Some(resource) = resources.first() {
857 CSVFormat::try_from(vec![resource.clone()])
858 .and_then(|f| f.write_to(Path::new(output_path)))
859 .map_err(|e| {
860 Error::conversion_error(
861 format!("Error writing CSV output: {}", e),
862 None,
863 )
864 })
865 } else {
866 Err(Error::InvalidResource(
867 "No resources to convert".to_string(),
868 ))
869 }
870 }
871 crate::formats::FormatType::TSV => {
872 if let Some(resource) = resources.first() {
873 TSVFormat::try_from(vec![resource.clone()])
874 .and_then(|f| f.write_to(Path::new(output_path)))
875 .map_err(|e| {
876 Error::conversion_error(
877 format!("Error writing TSV output: {}", e),
878 None,
879 )
880 })
881 } else {
882 Err(Error::InvalidResource(
883 "No resources to convert".to_string(),
884 ))
885 }
886 }
887 }
888 }
889
890 /// Reads a resource file given its path and explicit format type.
891 ///
892 /// # Parameters
893 /// - `path`: Path to the resource file.
894 /// - `format_type`: The format type of the resource file.
895 ///
896 /// # Returns
897 ///
898 /// `Ok(())` if the file was successfully read and resources loaded,
899 /// or an `Error` otherwise.
900 pub fn read_file_by_type<P: AsRef<Path>>(
901 &mut self,
902 path: P,
903 format_type: FormatType,
904 ) -> Result<(), Error> {
905 let language = infer_language_from_path(&path, &format_type)?;
906
907 let domain = path
908 .as_ref()
909 .file_stem()
910 .and_then(|s| s.to_str())
911 .unwrap_or_default()
912 .to_string();
913 let path = path.as_ref();
914
915 let mut new_resources = match &format_type {
916 FormatType::Strings(_) => {
917 vec![Resource::from(StringsFormat::read_from(path)?)]
918 }
919 FormatType::AndroidStrings(_) => {
920 vec![Resource::from(AndroidStringsFormat::read_from(path)?)]
921 }
922 FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(path)?)?,
923 FormatType::CSV => {
924 // Parse CSV format and convert to resources
925 let csv_format = CSVFormat::read_from(path)?;
926 Vec::<Resource>::try_from(csv_format)?
927 }
928 FormatType::TSV => {
929 // Parse TSV format and convert to resources
930 let tsv_format = TSVFormat::read_from(path)?;
931 Vec::<Resource>::try_from(tsv_format)?
932 }
933 };
934
935 for new_resource in &mut new_resources {
936 if let Some(ref lang) = language {
937 new_resource.metadata.language = lang.clone();
938 }
939 new_resource.metadata.domain = domain.clone();
940 new_resource
941 .metadata
942 .custom
943 .insert("format".to_string(), format_type.to_string());
944 }
945 self.resources.append(&mut new_resources);
946
947 Ok(())
948 }
949
950 /// Reads a resource file by inferring its format from the file extension.
951 /// Optionally infers language from the path if not provided.
952 ///
953 /// # Parameters
954 /// - `path`: Path to the resource file.
955 /// - `lang`: Optional language code to use.
956 ///
957 /// # Returns
958 ///
959 /// `Ok(())` if the file was successfully read,
960 /// or an `Error` if the format is unsupported or reading fails.
961 pub fn read_file_by_extension<P: AsRef<Path>>(
962 &mut self,
963 path: P,
964 lang: Option<String>,
965 ) -> Result<(), Error> {
966 let format_type = match path.as_ref().extension().and_then(|s| s.to_str()) {
967 Some("xml") => FormatType::AndroidStrings(lang),
968 Some("strings") => FormatType::Strings(lang),
969 Some("xcstrings") => FormatType::Xcstrings,
970 Some("csv") => FormatType::CSV,
971 Some("tsv") => FormatType::TSV,
972 extension => {
973 return Err(Error::UnsupportedFormat(format!(
974 "Unsupported file extension: {:?}.",
975 extension
976 )));
977 }
978 };
979
980 self.read_file_by_type(path, format_type)?;
981
982 Ok(())
983 }
984
985 /// Writes all managed resources back to their respective files,
986 /// grouped by domain.
987 ///
988 /// # Returns
989 ///
990 /// `Ok(())` if all writes succeed, or an `Error` otherwise.
991 pub fn write_to_file(&self) -> Result<(), Error> {
992 // Group resources by the domain in a HashMap
993 let mut grouped_resources: std::collections::HashMap<String, Vec<Resource>> =
994 std::collections::HashMap::new();
995 for resource in &*self.resources {
996 let domain = resource.metadata.domain.clone();
997 grouped_resources
998 .entry(domain)
999 .or_default()
1000 .push(resource.clone());
1001 }
1002
1003 // Iterate the map and write each resource to its respective file
1004 for (domain, resources) in grouped_resources {
1005 write_resources_to_file(&resources, &domain)?;
1006 }
1007
1008 Ok(())
1009 }
1010
1011 /// Caches the current resources to a JSON file.
1012 ///
1013 /// # Parameters
1014 /// - `path`: Destination file path for the cache.
1015 ///
1016 /// # Returns
1017 ///
1018 /// `Ok(())` if caching succeeds, or an `Error` if file I/O or serialization fails.
1019 pub fn cache_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
1020 let path = path.as_ref();
1021 if let Some(parent) = path.parent() {
1022 std::fs::create_dir_all(parent).map_err(Error::Io)?;
1023 }
1024 let mut writer = std::fs::File::create(path).map_err(Error::Io)?;
1025 serde_json::to_writer(&mut writer, &*self.resources).map_err(Error::Parse)?;
1026 Ok(())
1027 }
1028
1029 /// Loads resources from a JSON cache file.
1030 ///
1031 /// # Parameters
1032 /// - `path`: Path to the JSON file containing cached resources.
1033 ///
1034 /// # Returns
1035 ///
1036 /// `Ok(Codec)` with loaded resources, or an `Error` if loading or deserialization fails.
1037 pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
1038 let mut reader = std::fs::File::open(path).map_err(Error::Io)?;
1039 let resources: Vec<Resource> =
1040 serde_json::from_reader(&mut reader).map_err(Error::Parse)?;
1041 Ok(Codec { resources })
1042 }
1043}
1044
1045/// Attempts to infer the language from the file path based on format conventions.
1046/// For Apple: looks for "{lang}.lproj"; for Android: "values-{lang}".
1047///
1048/// # Parameters
1049/// - `path`: The file path to analyze.
1050/// - `format_type`: The format type to consider for language inference.
1051///
1052/// # Returns
1053///
1054/// `Ok(Some(language_code))` if a language could be inferred,
1055/// `Ok(None)` if no language is applicable for the format,
1056/// or an `Error` if inference fails.
1057pub fn infer_language_from_path<P: AsRef<Path>>(
1058 path: &P,
1059 format_type: &FormatType,
1060) -> Result<Option<String>, Error> {
1061 match &format_type {
1062 FormatType::AndroidStrings(lang) | FormatType::Strings(lang) => {
1063 let processed_lang = if let Some(lang) = lang {
1064 lang.clone()
1065 } else {
1066 path.as_ref()
1067 .components()
1068 .rev()
1069 .find_map(|c| {
1070 let component = c.as_os_str().to_str()?;
1071 if component.ends_with(".lproj") {
1072 Some(component.trim_end_matches(".lproj").to_string())
1073 } else if component.starts_with("values-") {
1074 Some(component.trim_start_matches("values-").to_string())
1075 } else {
1076 None
1077 }
1078 })
1079 .ok_or(Error::UnknownFormat(
1080 "Failed to infer language from path, please provide a language code manually."
1081 .to_string(),
1082 ))?
1083 };
1084
1085 Ok(Some(processed_lang))
1086 }
1087 FormatType::CSV => {
1088 // CSV format handles languages internally from headers, don't infer from path
1089 Ok(None)
1090 }
1091 FormatType::TSV => {
1092 // TSV format handles languages internally from headers, don't infer from path
1093 Ok(None)
1094 }
1095 _ => Ok(None),
1096 }
1097}
1098
1099/// Writes one or more resources to a file based on their format metadata.
1100/// Supports formats with single or multiple resources per file.
1101///
1102/// # Parameters
1103/// - `resources`: Slice of resources to write.
1104/// - `file_path`: Destination file path.
1105///
1106/// # Returns
1107///
1108/// `Ok(())` if writing succeeds, or an `Error` if the format is unsupported or writing fails.
1109fn write_resources_to_file(resources: &[Resource], file_path: &String) -> Result<(), Error> {
1110 let path = Path::new(&file_path);
1111
1112 if let Some(first) = resources.first() {
1113 match first.metadata.custom.get("format").map(String::as_str) {
1114 Some("AndroidStrings") => AndroidStringsFormat::from(first.clone()).write_to(path)?,
1115 Some("Strings") => StringsFormat::try_from(first.clone())?.write_to(path)?,
1116 Some("Xcstrings") => XcstringsFormat::try_from(resources.to_vec())?.write_to(path)?,
1117 Some("CSV") => CSVFormat::try_from(vec![first.clone()])?.write_to(path)?,
1118 Some("TSV") => TSVFormat::try_from(vec![first.clone()])?.write_to(path)?,
1119 _ => Err(Error::UnsupportedFormat(format!(
1120 "Unsupported format: {:?}",
1121 first.metadata.custom.get("format")
1122 )))?,
1123 }
1124 }
1125
1126 Ok(())
1127}
1128
1129/// Convert a localization file from one format to another.
1130///
1131/// # Arguments
1132///
1133/// * `input` - The input file path.
1134/// * `input_format` - The format of the input file.
1135/// * `output` - The output file path.
1136/// * `output_format` - The format of the output file.
1137///
1138/// # Errors
1139///
1140/// Returns an `Error` if reading, parsing, converting, or writing fails.
1141///
1142/// # Example
1143///
1144/// ```rust,no_run
1145/// use langcodec::{convert, formats::FormatType};
1146/// convert(
1147/// "Localizable.strings",
1148/// FormatType::Strings(None),
1149/// "strings.xml",
1150/// FormatType::AndroidStrings(None),
1151/// )?;
1152/// # Ok::<(), langcodec::Error>(())
1153/// ```
1154pub fn convert<P: AsRef<Path>>(
1155 input: P,
1156 input_format: FormatType,
1157 output: P,
1158 output_format: FormatType,
1159) -> Result<(), Error> {
1160 use crate::formats::{
1161 AndroidStringsFormat, CSVFormat, StringsFormat, TSVFormat, XcstringsFormat,
1162 };
1163 use crate::traits::Parser;
1164
1165 // Propagate language code from input to output format if not specified
1166 let output_format = if let Some(lang) = input_format.language() {
1167 output_format.with_language(Some(lang.clone()))
1168 } else {
1169 output_format
1170 };
1171
1172 if !input_format.matches_language_of(&output_format) {
1173 return Err(Error::InvalidResource(
1174 "Input and output formats must match in language.".to_string(),
1175 ));
1176 }
1177
1178 // Read input as resources
1179 let resources = match input_format {
1180 FormatType::AndroidStrings(_) => vec![AndroidStringsFormat::read_from(&input)?.into()],
1181 FormatType::Strings(_) => vec![StringsFormat::read_from(&input)?.into()],
1182 FormatType::Xcstrings => {
1183 Vec::<crate::types::Resource>::try_from(XcstringsFormat::read_from(&input)?)?
1184 }
1185 FormatType::CSV => Vec::<Resource>::try_from(CSVFormat::read_from(&input)?)?,
1186 FormatType::TSV => Vec::<Resource>::try_from(TSVFormat::read_from(&input)?)?,
1187 };
1188
1189 // Helper to extract resource by language if present, or first one
1190 let pick_resource = |lang: Option<String>| -> Option<crate::types::Resource> {
1191 match lang {
1192 Some(l) => resources.iter().find(|r| r.metadata.language == l).cloned(),
1193 None => resources.first().cloned(),
1194 }
1195 };
1196
1197 match output_format {
1198 FormatType::AndroidStrings(lang) => {
1199 let resource = pick_resource(lang);
1200 if let Some(res) = resource {
1201 AndroidStringsFormat::from(res).write_to(&output)
1202 } else {
1203 Err(Error::InvalidResource(
1204 "No matching resource for output language.".to_string(),
1205 ))
1206 }
1207 }
1208 FormatType::Strings(lang) => {
1209 let resource = pick_resource(lang);
1210 if let Some(res) = resource {
1211 StringsFormat::try_from(res)?.write_to(&output)
1212 } else {
1213 Err(Error::InvalidResource(
1214 "No matching resource for output language.".to_string(),
1215 ))
1216 }
1217 }
1218 FormatType::Xcstrings => XcstringsFormat::try_from(resources)?.write_to(&output),
1219 FormatType::CSV => {
1220 let resource = pick_resource(None);
1221 if let Some(res) = resource {
1222 CSVFormat::try_from(vec![res])?.write_to(&output)
1223 } else {
1224 Err(Error::InvalidResource(
1225 "No matching resource for output language.".to_string(),
1226 ))
1227 }
1228 }
1229 FormatType::TSV => {
1230 let resource = pick_resource(None);
1231 if let Some(res) = resource {
1232 TSVFormat::try_from(vec![res])?.write_to(&output)
1233 } else {
1234 Err(Error::InvalidResource(
1235 "No matching resource for output language.".to_string(),
1236 ))
1237 }
1238 }
1239 }
1240}
1241
1242/// Infers a [`FormatType`] from a file path's extension.
1243///
1244/// Returns `Some(FormatType)` if the extension matches a known format, otherwise `None`.
1245///
1246/// # Example
1247/// ```rust
1248/// use langcodec::formats::FormatType;
1249/// use langcodec::codec::infer_format_from_extension;
1250/// assert_eq!(
1251/// infer_format_from_extension("foo.strings"),
1252/// Some(FormatType::Strings(None))
1253/// );
1254/// assert_eq!(
1255/// infer_format_from_extension("foo.xml"),
1256/// Some(FormatType::AndroidStrings(None))
1257/// );
1258/// assert_eq!(
1259/// infer_format_from_extension("foo.xcstrings"),
1260/// Some(FormatType::Xcstrings)
1261/// );
1262/// assert_eq!(
1263/// infer_format_from_extension("foo.txt"),
1264/// None
1265/// );
1266/// ```
1267pub fn infer_format_from_extension<P: AsRef<Path>>(path: P) -> Option<FormatType> {
1268 match path.as_ref().extension().and_then(|s| s.to_str()) {
1269 Some("xml") => Some(FormatType::AndroidStrings(None)),
1270 Some("strings") => Some(FormatType::Strings(None)),
1271 Some("xcstrings") => Some(FormatType::Xcstrings),
1272 Some("csv") => Some(FormatType::CSV),
1273 Some("tsv") => Some(FormatType::TSV),
1274 _ => None,
1275 }
1276}
1277
1278/// Infers the localization file format and language code from a path.
1279///
1280/// - For Apple `.strings`: extracts language from `??.lproj/` (e.g. `en.lproj/Localizable.strings`)
1281/// - For Android `strings.xml`: extracts language from `values-??/` (e.g. `values-es/strings.xml`)
1282/// - For `.xcstrings`: returns format without language info (contained in file)
1283///
1284/// # Examples
1285/// ```rust
1286/// use langcodec::formats::FormatType;
1287/// use langcodec::codec::infer_format_from_path;
1288/// assert_eq!(
1289/// infer_format_from_path("ar.lproj/Localizable.strings"),
1290/// Some(FormatType::Strings(Some("ar".to_string())))
1291/// );
1292/// assert_eq!(
1293/// infer_format_from_path("en.lproj/Localizable.strings"),
1294/// Some(FormatType::Strings(Some("en".to_string())))
1295/// );
1296/// assert_eq!(
1297/// infer_format_from_path("Base.lproj/Localizable.strings"),
1298/// Some(FormatType::Strings(Some("Base".to_string())))
1299/// );
1300/// assert_eq!(
1301/// infer_format_from_path("values-es/strings.xml"),
1302/// Some(FormatType::AndroidStrings(Some("es".to_string())))
1303/// );
1304/// assert_eq!(
1305/// infer_format_from_path("values/strings.xml"),
1306/// Some(FormatType::AndroidStrings(None))
1307/// );
1308/// assert_eq!(
1309/// infer_format_from_path("Localizable.xcstrings"),
1310/// Some(FormatType::Xcstrings)
1311/// );
1312/// ```
1313pub fn infer_format_from_path<P: AsRef<Path>>(path: P) -> Option<FormatType> {
1314 match infer_format_from_extension(&path) {
1315 Some(format) => match format {
1316 FormatType::Xcstrings => Some(format),
1317 FormatType::CSV | FormatType::TSV => Some(format), // Multi-language formats, no language inference needed
1318 FormatType::AndroidStrings(_) | FormatType::Strings(_) => {
1319 let lang = infer_language_from_path(&path, &format).ok().flatten();
1320 Some(format.with_language(lang))
1321 }
1322 },
1323 None => None,
1324 }
1325}
1326
1327/// Convert a localization file from one format to another, inferring formats from file extensions.
1328///
1329/// This function attempts to infer the input and output formats from their file extensions.
1330/// Returns an error if either format cannot be inferred.
1331///
1332/// # Arguments
1333///
1334/// * `input` - The input file path.
1335/// * `output` - The output file path.
1336///
1337/// # Errors
1338///
1339/// Returns an `Error` if the format cannot be inferred, or if conversion fails.
1340///
1341/// # Example
1342///
1343/// ```rust,no_run
1344/// use langcodec::convert_auto;
1345/// convert_auto("Localizable.strings", "strings.xml")?;
1346/// # Ok::<(), langcodec::Error>(())
1347/// ```
1348pub fn convert_auto<P: AsRef<Path>>(input: P, output: P) -> Result<(), Error> {
1349 let input_format = infer_format_from_path(&input).ok_or_else(|| {
1350 Error::UnknownFormat(format!(
1351 "Cannot infer input format from extension: {:?}",
1352 input.as_ref().extension()
1353 ))
1354 })?;
1355 let output_format = infer_format_from_path(&output).ok_or_else(|| {
1356 Error::UnknownFormat(format!(
1357 "Cannot infer output format from extension: {:?}",
1358 output.as_ref().extension()
1359 ))
1360 })?;
1361 convert(input, input_format, output, output_format)
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367 use crate::types::{Entry, EntryStatus, Metadata, Translation};
1368
1369 #[test]
1370 fn test_builder_pattern() {
1371 // Test creating an empty codec
1372 let codec = Codec::builder().build();
1373 assert_eq!(codec.resources.len(), 0);
1374
1375 // Test adding resources directly
1376 let resource1 = Resource {
1377 metadata: Metadata {
1378 language: "en".to_string(),
1379 domain: "test".to_string(),
1380 custom: std::collections::HashMap::new(),
1381 },
1382 entries: vec![Entry {
1383 id: "hello".to_string(),
1384 value: Translation::Singular("Hello".to_string()),
1385 comment: None,
1386 status: EntryStatus::Translated,
1387 custom: std::collections::HashMap::new(),
1388 }],
1389 };
1390
1391 let resource2 = Resource {
1392 metadata: Metadata {
1393 language: "fr".to_string(),
1394 domain: "test".to_string(),
1395 custom: std::collections::HashMap::new(),
1396 },
1397 entries: vec![Entry {
1398 id: "hello".to_string(),
1399 value: Translation::Singular("Bonjour".to_string()),
1400 comment: None,
1401 status: EntryStatus::Translated,
1402 custom: std::collections::HashMap::new(),
1403 }],
1404 };
1405
1406 let codec = Codec::builder()
1407 .add_resource(resource1.clone())
1408 .add_resource(resource2.clone())
1409 .build();
1410
1411 assert_eq!(codec.resources.len(), 2);
1412 assert_eq!(codec.resources[0].metadata.language, "en");
1413 assert_eq!(codec.resources[1].metadata.language, "fr");
1414 }
1415
1416 #[test]
1417 fn test_builder_validation() {
1418 // Test validation with empty language
1419 let resource_without_language = Resource {
1420 metadata: Metadata {
1421 language: "".to_string(),
1422 domain: "test".to_string(),
1423 custom: std::collections::HashMap::new(),
1424 },
1425 entries: vec![],
1426 };
1427
1428 let result = Codec::builder()
1429 .add_resource(resource_without_language)
1430 .build_and_validate();
1431
1432 assert!(result.is_err());
1433 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1434
1435 // Test validation with duplicate languages
1436 let resource1 = Resource {
1437 metadata: Metadata {
1438 language: "en".to_string(),
1439 domain: "test".to_string(),
1440 custom: std::collections::HashMap::new(),
1441 },
1442 entries: vec![],
1443 };
1444
1445 let resource2 = Resource {
1446 metadata: Metadata {
1447 language: "en".to_string(), // Duplicate language
1448 domain: "test".to_string(),
1449 custom: std::collections::HashMap::new(),
1450 },
1451 entries: vec![],
1452 };
1453
1454 let result = Codec::builder()
1455 .add_resource(resource1)
1456 .add_resource(resource2)
1457 .build_and_validate();
1458
1459 assert!(result.is_err());
1460 assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1461 }
1462
1463 #[test]
1464 fn test_builder_add_resources() {
1465 let resources = vec![
1466 Resource {
1467 metadata: Metadata {
1468 language: "en".to_string(),
1469 domain: "test".to_string(),
1470 custom: std::collections::HashMap::new(),
1471 },
1472 entries: vec![],
1473 },
1474 Resource {
1475 metadata: Metadata {
1476 language: "fr".to_string(),
1477 domain: "test".to_string(),
1478 custom: std::collections::HashMap::new(),
1479 },
1480 entries: vec![],
1481 },
1482 ];
1483
1484 let codec = Codec::builder().add_resources(resources).build();
1485 assert_eq!(codec.resources.len(), 2);
1486 assert_eq!(codec.resources[0].metadata.language, "en");
1487 assert_eq!(codec.resources[1].metadata.language, "fr");
1488 }
1489
1490 #[test]
1491 fn test_modification_methods() {
1492 use crate::types::{EntryStatus, Translation};
1493
1494 // Create a codec with some test data
1495 let mut codec = Codec::new();
1496
1497 // Add resources
1498 let resource1 = Resource {
1499 metadata: Metadata {
1500 language: "en".to_string(),
1501 domain: "test".to_string(),
1502 custom: std::collections::HashMap::new(),
1503 },
1504 entries: vec![Entry {
1505 id: "welcome".to_string(),
1506 value: Translation::Singular("Hello".to_string()),
1507 comment: None,
1508 status: EntryStatus::Translated,
1509 custom: std::collections::HashMap::new(),
1510 }],
1511 };
1512
1513 let resource2 = Resource {
1514 metadata: Metadata {
1515 language: "fr".to_string(),
1516 domain: "test".to_string(),
1517 custom: std::collections::HashMap::new(),
1518 },
1519 entries: vec![Entry {
1520 id: "welcome".to_string(),
1521 value: Translation::Singular("Bonjour".to_string()),
1522 comment: None,
1523 status: EntryStatus::Translated,
1524 custom: std::collections::HashMap::new(),
1525 }],
1526 };
1527
1528 codec.add_resource(resource1);
1529 codec.add_resource(resource2);
1530
1531 // Test find_entries
1532 let entries = codec.find_entries("welcome");
1533 assert_eq!(entries.len(), 2);
1534 assert_eq!(entries[0].0.metadata.language, "en");
1535 assert_eq!(entries[1].0.metadata.language, "fr");
1536
1537 // Test find_entry
1538 let entry = codec.find_entry("welcome", "en");
1539 assert!(entry.is_some());
1540 assert_eq!(entry.unwrap().id, "welcome");
1541
1542 // Test find_entry_mut and update
1543 if let Some(entry) = codec.find_entry_mut("welcome", "en") {
1544 entry.value = Translation::Singular("Hello, World!".to_string());
1545 entry.status = EntryStatus::NeedsReview;
1546 }
1547
1548 // Verify the update
1549 let updated_entry = codec.find_entry("welcome", "en").unwrap();
1550 assert_eq!(updated_entry.value.to_string(), "Hello, World!");
1551 assert_eq!(updated_entry.status, EntryStatus::NeedsReview);
1552
1553 // Test update_translation
1554 codec
1555 .update_translation(
1556 "welcome",
1557 "fr",
1558 Translation::Singular("Bonjour, le monde!".to_string()),
1559 Some(EntryStatus::NeedsReview),
1560 )
1561 .unwrap();
1562
1563 // Test add_entry
1564 codec
1565 .add_entry(
1566 "new_key",
1567 "en",
1568 Translation::Singular("New message".to_string()),
1569 Some("A new message".to_string()),
1570 Some(EntryStatus::New),
1571 )
1572 .unwrap();
1573
1574 assert!(codec.has_entry("new_key", "en"));
1575 assert_eq!(codec.entry_count("en"), 2);
1576
1577 // Test remove_entry
1578 codec.remove_entry("new_key", "en").unwrap();
1579 assert!(!codec.has_entry("new_key", "en"));
1580 assert_eq!(codec.entry_count("en"), 1);
1581
1582 // Test copy_entry
1583 codec.copy_entry("welcome", "en", "fr", true).unwrap();
1584 let copied_entry = codec.find_entry("welcome", "fr").unwrap();
1585 assert_eq!(copied_entry.status, EntryStatus::New);
1586
1587 // Test languages
1588 let languages: Vec<_> = codec.languages().collect();
1589 assert_eq!(languages.len(), 2);
1590 assert!(languages.contains(&"en"));
1591 assert!(languages.contains(&"fr"));
1592
1593 // Test all_keys
1594 let keys: Vec<_> = codec.all_keys().collect();
1595 assert_eq!(keys.len(), 1);
1596 assert!(keys.contains(&"welcome"));
1597 }
1598
1599 #[test]
1600 fn test_validation() {
1601 let mut codec = Codec::new();
1602
1603 // Test validation with empty language
1604 let resource_without_language = Resource {
1605 metadata: Metadata {
1606 language: "".to_string(),
1607 domain: "test".to_string(),
1608 custom: std::collections::HashMap::new(),
1609 },
1610 entries: vec![],
1611 };
1612
1613 codec.add_resource(resource_without_language);
1614 assert!(codec.validate().is_err());
1615
1616 // Test validation with duplicate languages
1617 let mut codec = Codec::new();
1618 let resource1 = Resource {
1619 metadata: Metadata {
1620 language: "en".to_string(),
1621 domain: "test".to_string(),
1622 custom: std::collections::HashMap::new(),
1623 },
1624 entries: vec![],
1625 };
1626
1627 let resource2 = Resource {
1628 metadata: Metadata {
1629 language: "en".to_string(), // Duplicate language
1630 domain: "test".to_string(),
1631 custom: std::collections::HashMap::new(),
1632 },
1633 entries: vec![],
1634 };
1635
1636 codec.add_resource(resource1);
1637 codec.add_resource(resource2);
1638 assert!(codec.validate().is_err());
1639
1640 // Test validation with missing translations
1641 let mut codec = Codec::new();
1642 let resource1 = Resource {
1643 metadata: Metadata {
1644 language: "en".to_string(),
1645 domain: "test".to_string(),
1646 custom: std::collections::HashMap::new(),
1647 },
1648 entries: vec![Entry {
1649 id: "welcome".to_string(),
1650 value: Translation::Singular("Hello".to_string()),
1651 comment: None,
1652 status: EntryStatus::Translated,
1653 custom: std::collections::HashMap::new(),
1654 }],
1655 };
1656
1657 let resource2 = Resource {
1658 metadata: Metadata {
1659 language: "fr".to_string(),
1660 domain: "test".to_string(),
1661 custom: std::collections::HashMap::new(),
1662 },
1663 entries: vec![], // Missing welcome entry
1664 };
1665
1666 codec.add_resource(resource1);
1667 codec.add_resource(resource2);
1668 assert!(codec.validate().is_err());
1669 }
1670
1671 #[test]
1672 fn test_convert_csv_to_xcstrings() {
1673 // Test CSV to XCStrings conversion
1674 let temp_dir = tempfile::tempdir().unwrap();
1675 let input_file = temp_dir.path().join("test.csv");
1676 let output_file = temp_dir.path().join("output.xcstrings");
1677
1678 let csv_content =
1679 "key,en,fr,de\nhello,Hello,Bonjour,Hallo\nbye,Goodbye,Au revoir,Auf Wiedersehen\n";
1680 std::fs::write(&input_file, csv_content).unwrap();
1681
1682 // First, let's see what the CSV parsing produces
1683 let csv_format = CSVFormat::read_from(&input_file).unwrap();
1684 let resources = Vec::<Resource>::try_from(csv_format).unwrap();
1685 println!("CSV parsed to {} resources:", resources.len());
1686 for (i, resource) in resources.iter().enumerate() {
1687 println!(
1688 " Resource {}: language={}, entries={}",
1689 i,
1690 resource.metadata.language,
1691 resource.entries.len()
1692 );
1693 for entry in &resource.entries {
1694 println!(" Entry: id={}, value={:?}", entry.id, entry.value);
1695 }
1696 }
1697
1698 let result = convert(
1699 &input_file,
1700 FormatType::CSV,
1701 &output_file,
1702 FormatType::Xcstrings,
1703 );
1704
1705 match result {
1706 Ok(()) => println!("✅ CSV to XCStrings conversion succeeded"),
1707 Err(e) => println!("❌ CSV to XCStrings conversion failed: {}", e),
1708 }
1709
1710 // Check the output file content
1711 if output_file.exists() {
1712 let content = std::fs::read_to_string(&output_file).unwrap();
1713 println!("Output file content: {}", content);
1714 }
1715
1716 // Clean up
1717 let _ = std::fs::remove_file(input_file);
1718 let _ = std::fs::remove_file(output_file);
1719 }
1720}