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::CSVRecord;
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::{AndroidStringsFormat, CSVRecord, StringsFormat, XcstringsFormat};
724        use std::path::Path;
725
726        // Infer format from output path
727        let format_type = infer_format_from_extension(output_path).ok_or_else(|| {
728            Error::InvalidResource(format!(
729                "Cannot infer format from output path: {}",
730                output_path
731            ))
732        })?;
733
734        match format_type {
735            crate::formats::FormatType::AndroidStrings(_) => {
736                AndroidStringsFormat::from(resource.clone())
737                    .write_to(Path::new(output_path))
738                    .map_err(|e| {
739                        Error::conversion_error(
740                            format!("Error writing AndroidStrings output: {}", e),
741                            None,
742                        )
743                    })
744            }
745            crate::formats::FormatType::Strings(_) => StringsFormat::try_from(resource.clone())
746                .and_then(|f| f.write_to(Path::new(output_path)))
747                .map_err(|e| {
748                    Error::conversion_error(format!("Error writing Strings output: {}", e), None)
749                }),
750            crate::formats::FormatType::Xcstrings => {
751                XcstringsFormat::try_from(vec![resource.clone()])
752                    .and_then(|f| f.write_to(Path::new(output_path)))
753                    .map_err(|e| {
754                        Error::conversion_error(
755                            format!("Error writing Xcstrings output: {}", e),
756                            None,
757                        )
758                    })
759            }
760            crate::formats::FormatType::CSV(_) => Vec::<CSVRecord>::try_from(resource.clone())
761                .and_then(|f| f.write_to(Path::new(output_path)))
762                .map_err(|e| {
763                    Error::conversion_error(format!("Error writing CSV output: {}", e), None)
764                }),
765        }
766    }
767
768    /// Converts a vector of resources to a specific output format.
769    ///
770    /// # Arguments
771    ///
772    /// * `resources` - The resources to convert
773    /// * `output_path` - The output file path
774    /// * `output_format` - The target format
775    ///
776    /// # Returns
777    ///
778    /// `Ok(())` on success, `Err(Error)` on failure.
779    ///
780    /// # Example
781    ///
782    /// ```rust, no_run
783    /// use langcodec::{Codec, types::{Resource, Metadata, Entry, Translation, EntryStatus}, formats::FormatType};
784    ///
785    /// let resources = vec![Resource {
786    ///     metadata: Metadata {
787    ///         language: "en".to_string(),
788    ///         domain: "domain".to_string(),
789    ///         custom: std::collections::HashMap::new(),
790    ///     },
791    ///     entries: vec![],
792    /// }];
793    /// Codec::convert_resources_to_format(
794    ///     resources,
795    ///     "output.strings",
796    ///     FormatType::Strings(None)
797    /// )?;
798    /// # Ok::<(), langcodec::Error>(())
799    /// ```
800    pub fn convert_resources_to_format(
801        resources: Vec<Resource>,
802        output_path: &str,
803        output_format: crate::formats::FormatType,
804    ) -> Result<(), Error> {
805        use crate::formats::{AndroidStringsFormat, CSVRecord, StringsFormat, XcstringsFormat};
806        use std::path::Path;
807
808        match output_format {
809            crate::formats::FormatType::AndroidStrings(_) => {
810                if let Some(resource) = resources.first() {
811                    AndroidStringsFormat::from(resource.clone())
812                        .write_to(Path::new(output_path))
813                        .map_err(|e| {
814                            Error::conversion_error(
815                                format!("Error writing AndroidStrings output: {}", e),
816                                None,
817                            )
818                        })
819                } else {
820                    Err(Error::InvalidResource(
821                        "No resources to convert".to_string(),
822                    ))
823                }
824            }
825            crate::formats::FormatType::Strings(_) => {
826                if let Some(resource) = resources.first() {
827                    StringsFormat::try_from(resource.clone())
828                        .and_then(|f| f.write_to(Path::new(output_path)))
829                        .map_err(|e| {
830                            Error::conversion_error(
831                                format!("Error writing Strings output: {}", e),
832                                None,
833                            )
834                        })
835                } else {
836                    Err(Error::InvalidResource(
837                        "No resources to convert".to_string(),
838                    ))
839                }
840            }
841            crate::formats::FormatType::Xcstrings => XcstringsFormat::try_from(resources)
842                .and_then(|f| f.write_to(Path::new(output_path)))
843                .map_err(|e| {
844                    Error::conversion_error(format!("Error writing Xcstrings output: {}", e), None)
845                }),
846            crate::formats::FormatType::CSV(_) => {
847                if let Some(resource) = resources.first() {
848                    Vec::<CSVRecord>::try_from(resource.clone())
849                        .and_then(|f| f.write_to(Path::new(output_path)))
850                        .map_err(|e| {
851                            Error::conversion_error(
852                                format!("Error writing CSV output: {}", e),
853                                None,
854                            )
855                        })
856                } else {
857                    Err(Error::InvalidResource(
858                        "No resources to convert".to_string(),
859                    ))
860                }
861            }
862        }
863    }
864
865    /// Reads a resource file given its path and explicit format type.
866    ///
867    /// # Parameters
868    /// - `path`: Path to the resource file.
869    /// - `format_type`: The format type of the resource file.
870    ///
871    /// # Returns
872    ///
873    /// `Ok(())` if the file was successfully read and resources loaded,
874    /// or an `Error` otherwise.
875    pub fn read_file_by_type<P: AsRef<Path>>(
876        &mut self,
877        path: P,
878        format_type: FormatType,
879    ) -> Result<(), Error> {
880        let language = infer_language_from_path(&path, &format_type)?;
881
882        let domain = path
883            .as_ref()
884            .file_stem()
885            .and_then(|s| s.to_str())
886            .unwrap_or_default()
887            .to_string();
888        let path = path.as_ref();
889
890        let mut new_resources = match &format_type {
891            FormatType::Strings(_) => {
892                vec![Resource::from(StringsFormat::read_from(path)?)]
893            }
894            FormatType::AndroidStrings(_) => {
895                vec![Resource::from(AndroidStringsFormat::read_from(path)?)]
896            }
897            FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(path)?)?,
898            FormatType::CSV(_) => {
899                vec![Resource::from(Vec::<CSVRecord>::read_from(path)?)]
900            }
901        };
902
903        for new_resource in &mut new_resources {
904            if let Some(ref lang) = language {
905                new_resource.metadata.language = lang.clone();
906            }
907            new_resource.metadata.domain = domain.clone();
908            new_resource
909                .metadata
910                .custom
911                .insert("format".to_string(), format_type.to_string());
912        }
913        self.resources.append(&mut new_resources);
914
915        Ok(())
916    }
917
918    /// Reads a resource file by inferring its format from the file extension.
919    /// Optionally infers language from the path if not provided.
920    ///
921    /// # Parameters
922    /// - `path`: Path to the resource file.
923    /// - `lang`: Optional language code to use.
924    ///
925    /// # Returns
926    ///
927    /// `Ok(())` if the file was successfully read,
928    /// or an `Error` if the format is unsupported or reading fails.
929    pub fn read_file_by_extension<P: AsRef<Path>>(
930        &mut self,
931        path: P,
932        lang: Option<String>,
933    ) -> Result<(), Error> {
934        let format_type = match path.as_ref().extension().and_then(|s| s.to_str()) {
935            Some("xml") => FormatType::AndroidStrings(lang),
936            Some("strings") => FormatType::Strings(lang),
937            Some("xcstrings") => FormatType::Xcstrings,
938            Some("csv") => FormatType::CSV(lang),
939            extension => {
940                return Err(Error::UnsupportedFormat(format!(
941                    "Unsupported file extension: {:?}.",
942                    extension
943                )));
944            }
945        };
946
947        self.read_file_by_type(path, format_type)?;
948
949        Ok(())
950    }
951
952    /// Writes all managed resources back to their respective files,
953    /// grouped by domain.
954    ///
955    /// # Returns
956    ///
957    /// `Ok(())` if all writes succeed, or an `Error` otherwise.
958    pub fn write_to_file(&self) -> Result<(), Error> {
959        // Group resources by the domain in a HashMap
960        let mut grouped_resources: std::collections::HashMap<String, Vec<Resource>> =
961            std::collections::HashMap::new();
962        for resource in &*self.resources {
963            let domain = resource.metadata.domain.clone();
964            grouped_resources
965                .entry(domain)
966                .or_default()
967                .push(resource.clone());
968        }
969
970        // Iterate the map and write each resource to its respective file
971        for (domain, resources) in grouped_resources {
972            write_resources_to_file(&resources, &domain)?;
973        }
974
975        Ok(())
976    }
977
978    /// Caches the current resources to a JSON file.
979    ///
980    /// # Parameters
981    /// - `path`: Destination file path for the cache.
982    ///
983    /// # Returns
984    ///
985    /// `Ok(())` if caching succeeds, or an `Error` if file I/O or serialization fails.
986    pub fn cache_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), Error> {
987        let path = path.as_ref();
988        if let Some(parent) = path.parent() {
989            std::fs::create_dir_all(parent).map_err(Error::Io)?;
990        }
991        let mut writer = std::fs::File::create(path).map_err(Error::Io)?;
992        serde_json::to_writer(&mut writer, &*self.resources).map_err(Error::Parse)?;
993        Ok(())
994    }
995
996    /// Loads resources from a JSON cache file.
997    ///
998    /// # Parameters
999    /// - `path`: Path to the JSON file containing cached resources.
1000    ///
1001    /// # Returns
1002    ///
1003    /// `Ok(Codec)` with loaded resources, or an `Error` if loading or deserialization fails.
1004    pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
1005        let mut reader = std::fs::File::open(path).map_err(Error::Io)?;
1006        let resources: Vec<Resource> =
1007            serde_json::from_reader(&mut reader).map_err(Error::Parse)?;
1008        Ok(Codec { resources })
1009    }
1010}
1011
1012/// Attempts to infer the language from the file path based on format conventions.
1013/// For Apple: looks for "{lang}.lproj"; for Android: "values-{lang}".
1014///
1015/// # Parameters
1016/// - `path`: The file path to analyze.
1017/// - `format_type`: The format type to consider for language inference.
1018///
1019/// # Returns
1020///
1021/// `Ok(Some(language_code))` if a language could be inferred,
1022/// `Ok(None)` if no language is applicable for the format,
1023/// or an `Error` if inference fails.
1024pub fn infer_language_from_path<P: AsRef<Path>>(
1025    path: &P,
1026    format_type: &FormatType,
1027) -> Result<Option<String>, Error> {
1028    match &format_type {
1029        FormatType::AndroidStrings(lang) | FormatType::Strings(lang) | FormatType::CSV(lang) => {
1030            let processed_lang = if let Some(lang) = lang {
1031                lang.clone()
1032            } else {
1033                path.as_ref()
1034                    .components()
1035                    .rev()
1036                    .find_map(|c| {
1037                        let component = c.as_os_str().to_str()?;
1038                        if component.ends_with(".lproj") {
1039                            Some(component.trim_end_matches(".lproj").to_string())
1040                        } else if component.starts_with("values-") {
1041                            Some(component.trim_start_matches("values-").to_string())
1042                        } else {
1043                            None
1044                        }
1045                    })
1046                    .ok_or(Error::UnknownFormat(
1047                        "Failed to infer language from path, please provide a language code manually."
1048                            .to_string(),
1049                    ))?
1050            };
1051
1052            Ok(Some(processed_lang))
1053        }
1054        _ => Ok(None),
1055    }
1056}
1057
1058/// Writes one or more resources to a file based on their format metadata.
1059/// Supports formats with single or multiple resources per file.
1060///
1061/// # Parameters
1062/// - `resources`: Slice of resources to write.
1063/// - `file_path`: Destination file path.
1064///
1065/// # Returns
1066///
1067/// `Ok(())` if writing succeeds, or an `Error` if the format is unsupported or writing fails.
1068fn write_resources_to_file(resources: &[Resource], file_path: &String) -> Result<(), Error> {
1069    let path = Path::new(&file_path);
1070
1071    if let Some(first) = resources.first() {
1072        match first.metadata.custom.get("format").map(String::as_str) {
1073            Some("AndroidStrings") => AndroidStringsFormat::from(first.clone()).write_to(path)?,
1074            Some("Strings") => StringsFormat::try_from(first.clone())?.write_to(path)?,
1075            Some("Xcstrings") => XcstringsFormat::try_from(resources.to_vec())?.write_to(path)?,
1076            Some("CSV") => Vec::<CSVRecord>::try_from(first.clone())?.write_to(path)?,
1077            _ => Err(Error::UnsupportedFormat(format!(
1078                "Unsupported format: {:?}",
1079                first.metadata.custom.get("format")
1080            )))?,
1081        }
1082    }
1083
1084    Ok(())
1085}
1086
1087/// Convert a localization file from one format to another.
1088///
1089/// # Arguments
1090///
1091/// * `input` - The input file path.
1092/// * `input_format` - The format of the input file.
1093/// * `output` - The output file path.
1094/// * `output_format` - The format of the output file.
1095///
1096/// # Errors
1097///
1098/// Returns an `Error` if reading, parsing, converting, or writing fails.
1099///
1100/// # Example
1101///
1102/// ```rust,no_run
1103/// use langcodec::{convert, formats::FormatType};
1104/// convert(
1105///     "Localizable.strings",
1106///     FormatType::Strings(None),
1107///     "strings.xml",
1108///     FormatType::AndroidStrings(None),
1109/// )?;
1110/// # Ok::<(), langcodec::Error>(())
1111/// ```
1112pub fn convert<P: AsRef<Path>>(
1113    input: P,
1114    input_format: FormatType,
1115    output: P,
1116    output_format: FormatType,
1117) -> Result<(), Error> {
1118    use crate::formats::{AndroidStringsFormat, StringsFormat, XcstringsFormat};
1119    use crate::traits::Parser;
1120
1121    // Propagate language code from input to output format if not specified
1122    let output_format = if let Some(lang) = input_format.language() {
1123        output_format.with_language(Some(lang.clone()))
1124    } else {
1125        output_format
1126    };
1127
1128    if !input_format.matches_language_of(&output_format) {
1129        return Err(Error::InvalidResource(
1130            "Input and output formats must match in language.".to_string(),
1131        ));
1132    }
1133
1134    // Read input as resources
1135    let resources = match input_format {
1136        FormatType::AndroidStrings(_) => vec![AndroidStringsFormat::read_from(&input)?.into()],
1137        FormatType::Strings(_) => vec![StringsFormat::read_from(&input)?.into()],
1138        FormatType::Xcstrings => {
1139            Vec::<crate::types::Resource>::try_from(XcstringsFormat::read_from(&input)?)?
1140        }
1141        FormatType::CSV(_) => vec![Vec::<CSVRecord>::read_from(&input)?.into()],
1142    };
1143
1144    // Helper to extract resource by language if present, or first one
1145    let pick_resource = |lang: Option<String>| -> Option<crate::types::Resource> {
1146        match lang {
1147            Some(l) => resources.iter().find(|r| r.metadata.language == l).cloned(),
1148            None => resources.first().cloned(),
1149        }
1150    };
1151
1152    match output_format {
1153        FormatType::AndroidStrings(lang) => {
1154            let resource = pick_resource(lang);
1155            if let Some(res) = resource {
1156                AndroidStringsFormat::from(res).write_to(&output)
1157            } else {
1158                Err(Error::InvalidResource(
1159                    "No matching resource for output language.".to_string(),
1160                ))
1161            }
1162        }
1163        FormatType::Strings(lang) => {
1164            let resource = pick_resource(lang);
1165            if let Some(res) = resource {
1166                StringsFormat::try_from(res)?.write_to(&output)
1167            } else {
1168                Err(Error::InvalidResource(
1169                    "No matching resource for output language.".to_string(),
1170                ))
1171            }
1172        }
1173        FormatType::Xcstrings => XcstringsFormat::try_from(resources)?.write_to(&output),
1174        FormatType::CSV(lang) => {
1175            let resource = pick_resource(lang);
1176            if let Some(res) = resource {
1177                Vec::<CSVRecord>::try_from(res)?.write_to(&output)
1178            } else {
1179                Err(Error::InvalidResource(
1180                    "No matching resource for output language.".to_string(),
1181                ))
1182            }
1183        }
1184    }
1185}
1186
1187/// Infers a [`FormatType`] from a file path's extension.
1188///
1189/// Returns `Some(FormatType)` if the extension matches a known format, otherwise `None`.
1190///
1191/// # Example
1192/// ```rust
1193/// use langcodec::formats::FormatType;
1194/// use langcodec::codec::infer_format_from_extension;
1195/// assert_eq!(
1196///     infer_format_from_extension("foo.strings"),
1197///     Some(FormatType::Strings(None))
1198/// );
1199/// assert_eq!(
1200///     infer_format_from_extension("foo.xml"),
1201///     Some(FormatType::AndroidStrings(None))
1202/// );
1203/// assert_eq!(
1204///     infer_format_from_extension("foo.xcstrings"),
1205///     Some(FormatType::Xcstrings)
1206/// );
1207/// assert_eq!(
1208///     infer_format_from_extension("foo.txt"),
1209///     None
1210/// );
1211/// ```
1212pub fn infer_format_from_extension<P: AsRef<Path>>(path: P) -> Option<FormatType> {
1213    match path.as_ref().extension().and_then(|s| s.to_str()) {
1214        Some("xml") => Some(FormatType::AndroidStrings(None)),
1215        Some("strings") => Some(FormatType::Strings(None)),
1216        Some("xcstrings") => Some(FormatType::Xcstrings),
1217        Some("csv") => Some(FormatType::CSV(None)),
1218        _ => None,
1219    }
1220}
1221
1222/// Infers the localization file format and language code from a path.
1223///
1224/// - For Apple `.strings`: extracts language from `??.lproj/` (e.g. `en.lproj/Localizable.strings`)
1225/// - For Android `strings.xml`: extracts language from `values-??/` (e.g. `values-es/strings.xml`)
1226/// - For `.xcstrings`: returns format without language info (contained in file)
1227///
1228/// # Examples
1229/// ```rust
1230/// use langcodec::formats::FormatType;
1231/// use langcodec::codec::infer_format_from_path;
1232/// assert_eq!(
1233///    infer_format_from_path("ar.lproj/Localizable.strings"),
1234///    Some(FormatType::Strings(Some("ar".to_string())))
1235/// );
1236/// assert_eq!(
1237///     infer_format_from_path("en.lproj/Localizable.strings"),
1238///     Some(FormatType::Strings(Some("en".to_string())))
1239/// );
1240/// assert_eq!(
1241///     infer_format_from_path("Base.lproj/Localizable.strings"),
1242///     Some(FormatType::Strings(Some("Base".to_string())))
1243/// );
1244/// assert_eq!(
1245///     infer_format_from_path("values-es/strings.xml"),
1246///     Some(FormatType::AndroidStrings(Some("es".to_string())))
1247/// );
1248/// assert_eq!(
1249///     infer_format_from_path("values/strings.xml"),
1250///     Some(FormatType::AndroidStrings(None))
1251/// );
1252/// assert_eq!(
1253///     infer_format_from_path("Localizable.xcstrings"),
1254///     Some(FormatType::Xcstrings)
1255/// );
1256/// ```
1257pub fn infer_format_from_path<P: AsRef<Path>>(path: P) -> Option<FormatType> {
1258    match infer_format_from_extension(&path) {
1259        Some(format) => match format {
1260            FormatType::Xcstrings => Some(format),
1261            FormatType::AndroidStrings(_) | FormatType::Strings(_) | FormatType::CSV(_) => {
1262                let lang = infer_language_from_path(&path, &format).ok().flatten();
1263                Some(format.with_language(lang))
1264            }
1265        },
1266        None => None,
1267    }
1268}
1269
1270/// Convert a localization file from one format to another, inferring formats from file extensions.
1271///
1272/// This function attempts to infer the input and output formats from their file extensions.
1273/// Returns an error if either format cannot be inferred.
1274///
1275/// # Arguments
1276///
1277/// * `input` - The input file path.
1278/// * `output` - The output file path.
1279///
1280/// # Errors
1281///
1282/// Returns an `Error` if the format cannot be inferred, or if conversion fails.
1283///
1284/// # Example
1285///
1286/// ```rust,no_run
1287/// use langcodec::convert_auto;
1288/// convert_auto("Localizable.strings", "strings.xml")?;
1289/// # Ok::<(), langcodec::Error>(())
1290/// ```
1291pub fn convert_auto<P: AsRef<Path>>(input: P, output: P) -> Result<(), Error> {
1292    let input_format = infer_format_from_path(&input).ok_or_else(|| {
1293        Error::UnknownFormat(format!(
1294            "Cannot infer input format from extension: {:?}",
1295            input.as_ref().extension()
1296        ))
1297    })?;
1298    let output_format = infer_format_from_path(&output).ok_or_else(|| {
1299        Error::UnknownFormat(format!(
1300            "Cannot infer output format from extension: {:?}",
1301            output.as_ref().extension()
1302        ))
1303    })?;
1304    convert(input, input_format, output, output_format)
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use super::*;
1310    use crate::types::{Entry, EntryStatus, Metadata, Translation};
1311
1312    #[test]
1313    fn test_builder_pattern() {
1314        // Test creating an empty codec
1315        let codec = Codec::builder().build();
1316        assert_eq!(codec.resources.len(), 0);
1317
1318        // Test adding resources directly
1319        let resource1 = Resource {
1320            metadata: Metadata {
1321                language: "en".to_string(),
1322                domain: "test".to_string(),
1323                custom: std::collections::HashMap::new(),
1324            },
1325            entries: vec![Entry {
1326                id: "hello".to_string(),
1327                value: Translation::Singular("Hello".to_string()),
1328                comment: None,
1329                status: EntryStatus::Translated,
1330                custom: std::collections::HashMap::new(),
1331            }],
1332        };
1333
1334        let resource2 = Resource {
1335            metadata: Metadata {
1336                language: "fr".to_string(),
1337                domain: "test".to_string(),
1338                custom: std::collections::HashMap::new(),
1339            },
1340            entries: vec![Entry {
1341                id: "hello".to_string(),
1342                value: Translation::Singular("Bonjour".to_string()),
1343                comment: None,
1344                status: EntryStatus::Translated,
1345                custom: std::collections::HashMap::new(),
1346            }],
1347        };
1348
1349        let codec = Codec::builder()
1350            .add_resource(resource1.clone())
1351            .add_resource(resource2.clone())
1352            .build();
1353
1354        assert_eq!(codec.resources.len(), 2);
1355        assert_eq!(codec.resources[0].metadata.language, "en");
1356        assert_eq!(codec.resources[1].metadata.language, "fr");
1357    }
1358
1359    #[test]
1360    fn test_builder_validation() {
1361        // Test validation with empty language
1362        let resource_without_language = Resource {
1363            metadata: Metadata {
1364                language: "".to_string(),
1365                domain: "test".to_string(),
1366                custom: std::collections::HashMap::new(),
1367            },
1368            entries: vec![],
1369        };
1370
1371        let result = Codec::builder()
1372            .add_resource(resource_without_language)
1373            .build_and_validate();
1374
1375        assert!(result.is_err());
1376        assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1377
1378        // Test validation with duplicate languages
1379        let resource1 = Resource {
1380            metadata: Metadata {
1381                language: "en".to_string(),
1382                domain: "test".to_string(),
1383                custom: std::collections::HashMap::new(),
1384            },
1385            entries: vec![],
1386        };
1387
1388        let resource2 = Resource {
1389            metadata: Metadata {
1390                language: "en".to_string(), // Duplicate language
1391                domain: "test".to_string(),
1392                custom: std::collections::HashMap::new(),
1393            },
1394            entries: vec![],
1395        };
1396
1397        let result = Codec::builder()
1398            .add_resource(resource1)
1399            .add_resource(resource2)
1400            .build_and_validate();
1401
1402        assert!(result.is_err());
1403        assert!(matches!(result.unwrap_err(), Error::Validation(_)));
1404    }
1405
1406    #[test]
1407    fn test_builder_add_resources() {
1408        let resources = vec![
1409            Resource {
1410                metadata: Metadata {
1411                    language: "en".to_string(),
1412                    domain: "test".to_string(),
1413                    custom: std::collections::HashMap::new(),
1414                },
1415                entries: vec![],
1416            },
1417            Resource {
1418                metadata: Metadata {
1419                    language: "fr".to_string(),
1420                    domain: "test".to_string(),
1421                    custom: std::collections::HashMap::new(),
1422                },
1423                entries: vec![],
1424            },
1425        ];
1426
1427        let codec = Codec::builder().add_resources(resources).build();
1428        assert_eq!(codec.resources.len(), 2);
1429        assert_eq!(codec.resources[0].metadata.language, "en");
1430        assert_eq!(codec.resources[1].metadata.language, "fr");
1431    }
1432
1433    #[test]
1434    fn test_modification_methods() {
1435        use crate::types::{EntryStatus, Translation};
1436
1437        // Create a codec with some test data
1438        let mut codec = Codec::new();
1439
1440        // Add resources
1441        let resource1 = Resource {
1442            metadata: Metadata {
1443                language: "en".to_string(),
1444                domain: "test".to_string(),
1445                custom: std::collections::HashMap::new(),
1446            },
1447            entries: vec![Entry {
1448                id: "welcome".to_string(),
1449                value: Translation::Singular("Hello".to_string()),
1450                comment: None,
1451                status: EntryStatus::Translated,
1452                custom: std::collections::HashMap::new(),
1453            }],
1454        };
1455
1456        let resource2 = Resource {
1457            metadata: Metadata {
1458                language: "fr".to_string(),
1459                domain: "test".to_string(),
1460                custom: std::collections::HashMap::new(),
1461            },
1462            entries: vec![Entry {
1463                id: "welcome".to_string(),
1464                value: Translation::Singular("Bonjour".to_string()),
1465                comment: None,
1466                status: EntryStatus::Translated,
1467                custom: std::collections::HashMap::new(),
1468            }],
1469        };
1470
1471        codec.add_resource(resource1);
1472        codec.add_resource(resource2);
1473
1474        // Test find_entries
1475        let entries = codec.find_entries("welcome");
1476        assert_eq!(entries.len(), 2);
1477        assert_eq!(entries[0].0.metadata.language, "en");
1478        assert_eq!(entries[1].0.metadata.language, "fr");
1479
1480        // Test find_entry
1481        let entry = codec.find_entry("welcome", "en");
1482        assert!(entry.is_some());
1483        assert_eq!(entry.unwrap().id, "welcome");
1484
1485        // Test find_entry_mut and update
1486        if let Some(entry) = codec.find_entry_mut("welcome", "en") {
1487            entry.value = Translation::Singular("Hello, World!".to_string());
1488            entry.status = EntryStatus::NeedsReview;
1489        }
1490
1491        // Verify the update
1492        let updated_entry = codec.find_entry("welcome", "en").unwrap();
1493        assert_eq!(updated_entry.value.to_string(), "Hello, World!");
1494        assert_eq!(updated_entry.status, EntryStatus::NeedsReview);
1495
1496        // Test update_translation
1497        codec
1498            .update_translation(
1499                "welcome",
1500                "fr",
1501                Translation::Singular("Bonjour, le monde!".to_string()),
1502                Some(EntryStatus::NeedsReview),
1503            )
1504            .unwrap();
1505
1506        // Test add_entry
1507        codec
1508            .add_entry(
1509                "new_key",
1510                "en",
1511                Translation::Singular("New message".to_string()),
1512                Some("A new message".to_string()),
1513                Some(EntryStatus::New),
1514            )
1515            .unwrap();
1516
1517        assert!(codec.has_entry("new_key", "en"));
1518        assert_eq!(codec.entry_count("en"), 2);
1519
1520        // Test remove_entry
1521        codec.remove_entry("new_key", "en").unwrap();
1522        assert!(!codec.has_entry("new_key", "en"));
1523        assert_eq!(codec.entry_count("en"), 1);
1524
1525        // Test copy_entry
1526        codec.copy_entry("welcome", "en", "fr", true).unwrap();
1527        let copied_entry = codec.find_entry("welcome", "fr").unwrap();
1528        assert_eq!(copied_entry.status, EntryStatus::New);
1529
1530        // Test languages
1531        let languages: Vec<_> = codec.languages().collect();
1532        assert_eq!(languages.len(), 2);
1533        assert!(languages.contains(&"en"));
1534        assert!(languages.contains(&"fr"));
1535
1536        // Test all_keys
1537        let keys: Vec<_> = codec.all_keys().collect();
1538        assert_eq!(keys.len(), 1);
1539        assert!(keys.contains(&"welcome"));
1540    }
1541
1542    #[test]
1543    fn test_validation() {
1544        let mut codec = Codec::new();
1545
1546        // Test validation with empty language
1547        let resource_without_language = Resource {
1548            metadata: Metadata {
1549                language: "".to_string(),
1550                domain: "test".to_string(),
1551                custom: std::collections::HashMap::new(),
1552            },
1553            entries: vec![],
1554        };
1555
1556        codec.add_resource(resource_without_language);
1557        assert!(codec.validate().is_err());
1558
1559        // Test validation with duplicate languages
1560        let mut codec = Codec::new();
1561        let resource1 = Resource {
1562            metadata: Metadata {
1563                language: "en".to_string(),
1564                domain: "test".to_string(),
1565                custom: std::collections::HashMap::new(),
1566            },
1567            entries: vec![],
1568        };
1569
1570        let resource2 = Resource {
1571            metadata: Metadata {
1572                language: "en".to_string(), // Duplicate language
1573                domain: "test".to_string(),
1574                custom: std::collections::HashMap::new(),
1575            },
1576            entries: vec![],
1577        };
1578
1579        codec.add_resource(resource1);
1580        codec.add_resource(resource2);
1581        assert!(codec.validate().is_err());
1582
1583        // Test validation with missing translations
1584        let mut codec = Codec::new();
1585        let resource1 = Resource {
1586            metadata: Metadata {
1587                language: "en".to_string(),
1588                domain: "test".to_string(),
1589                custom: std::collections::HashMap::new(),
1590            },
1591            entries: vec![Entry {
1592                id: "welcome".to_string(),
1593                value: Translation::Singular("Hello".to_string()),
1594                comment: None,
1595                status: EntryStatus::Translated,
1596                custom: std::collections::HashMap::new(),
1597            }],
1598        };
1599
1600        let resource2 = Resource {
1601            metadata: Metadata {
1602                language: "fr".to_string(),
1603                domain: "test".to_string(),
1604                custom: std::collections::HashMap::new(),
1605            },
1606            entries: vec![], // Missing welcome entry
1607        };
1608
1609        codec.add_resource(resource1);
1610        codec.add_resource(resource2);
1611        assert!(codec.validate().is_err());
1612    }
1613}