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