langcodec/
codec.rs

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