Skip to main content

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