langcodec/
codec.rs

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