langcodec/
converter.rs

1//! Format conversion utilities for langcodec.
2//!
3//! This module provides functions for converting between different localization file formats,
4//! format inference, and utility functions for working with resources.
5
6use crate::{
7    ConflictStrategy,
8    error::Error,
9    formats::{
10        AndroidStringsFormat, CSVFormat, FormatType, StringsFormat, TSVFormat, XcstringsFormat,
11    },
12    placeholder::normalize_placeholders,
13    traits::Parser,
14    types::Resource,
15};
16use std::path::Path;
17
18/// Convert a vector of resources to a specific output format.
19///
20/// # Arguments
21///
22/// * `resources` - The resources to convert
23/// * `output_path` - The output file path
24/// * `output_format` - The target format
25///
26/// # Returns
27///
28/// `Ok(())` on success, `Err(Error)` on failure.
29///
30/// # Example
31///
32/// ```rust, no_run
33/// use langcodec::{types::{Resource, Metadata, Entry, Translation, EntryStatus}, formats::FormatType, converter::convert_resources_to_format};
34///
35/// let resources = vec![Resource {
36///     metadata: Metadata {
37///         language: "en".to_string(),
38///         domain: "domain".to_string(),
39///         custom: std::collections::HashMap::new(),
40///     },
41///     entries: vec![],
42/// }];
43/// convert_resources_to_format(
44///     resources,
45///     "output.strings",
46///     FormatType::Strings(None)
47/// )?;
48/// # Ok::<(), langcodec::Error>(())
49/// ```
50pub fn convert_resources_to_format(
51    resources: Vec<Resource>,
52    output_path: &str,
53    output_format: FormatType,
54) -> Result<(), Error> {
55    match output_format {
56        FormatType::AndroidStrings(_) => {
57            if let Some(resource) = resources.into_iter().next() {
58                AndroidStringsFormat::from(resource)
59                    .write_to(Path::new(output_path))
60                    .map_err(|e| {
61                        Error::conversion_error(
62                            format!("Error writing AndroidStrings output: {}", e),
63                            None,
64                        )
65                    })
66            } else {
67                Err(Error::InvalidResource(
68                    "No resources to convert".to_string(),
69                ))
70            }
71        }
72        FormatType::Strings(_) => {
73            if let Some(resource) = resources.into_iter().next() {
74                StringsFormat::try_from(resource)
75                    .and_then(|f| f.write_to(Path::new(output_path)))
76                    .map_err(|e| {
77                        Error::conversion_error(
78                            format!("Error writing Strings output: {}", e),
79                            None,
80                        )
81                    })
82            } else {
83                Err(Error::InvalidResource(
84                    "No resources to convert".to_string(),
85                ))
86            }
87        }
88        FormatType::Xcstrings => XcstringsFormat::try_from(resources)
89            .and_then(|f| f.write_to(Path::new(output_path)))
90            .map_err(|e| {
91                Error::conversion_error(format!("Error writing Xcstrings output: {}", e), None)
92            }),
93        FormatType::CSV => CSVFormat::try_from(resources)
94            .and_then(|f| f.write_to(Path::new(output_path)))
95            .map_err(|e| Error::conversion_error(format!("Error writing CSV output: {}", e), None)),
96        FormatType::TSV => TSVFormat::try_from(resources)
97            .and_then(|f| f.write_to(Path::new(output_path)))
98            .map_err(|e| Error::conversion_error(format!("Error writing TSV output: {}", e), None)),
99    }
100}
101
102/// Convert a localization file from one format to another.
103///
104/// # Arguments
105///
106/// * `input` - The input file path.
107/// * `input_format` - The format of the input file.
108/// * `output` - The output file path.
109/// * `output_format` - The format of the output file.
110///
111/// # Errors
112///
113/// Returns an `Error` if reading, parsing, converting, or writing fails.
114///
115/// # Example
116///
117/// ```rust,no_run
118/// use langcodec::{converter::convert, formats::FormatType};
119/// convert(
120///     "Localizable.strings",
121///     FormatType::Strings(None),
122///     "strings.xml",
123///     FormatType::AndroidStrings(None),
124/// )?;
125/// # Ok::<(), langcodec::Error>(())
126/// ```
127pub fn convert<P: AsRef<Path>>(
128    input: P,
129    input_format: FormatType,
130    output: P,
131    output_format: FormatType,
132) -> Result<(), Error> {
133    // Propagate language code from input to output format if not specified
134    let output_format = if let Some(lang) = input_format.language() {
135        output_format.with_language(Some(lang.clone()))
136    } else {
137        output_format
138    };
139
140    if !input_format.matches_language_of(&output_format) {
141        return Err(Error::InvalidResource(
142            "Input and output formats must match in language.".to_string(),
143        ));
144    }
145
146    // Read input as resources
147    let mut resources = match input_format {
148        FormatType::AndroidStrings(_) => vec![AndroidStringsFormat::read_from(input)?.into()],
149        FormatType::Strings(_) => vec![StringsFormat::read_from(input)?.into()],
150        FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(input)?)?,
151        FormatType::CSV => Vec::<Resource>::try_from(CSVFormat::read_from(input)?)?,
152        FormatType::TSV => Vec::<Resource>::try_from(TSVFormat::read_from(input)?)?,
153    };
154
155    // Ensure language is set for single-language inputs if provided on input_format
156    if let Some(l) = input_format.language().cloned() {
157        for res in &mut resources {
158            if res.metadata.language.is_empty() {
159                res.metadata.language = l.clone();
160            }
161        }
162    }
163
164    // Helper to extract resource by language if present, or first one
165    let pick_resource = |lang: Option<String>| -> Option<Resource> {
166        match lang {
167            Some(l) => resources.iter().find(|r| r.metadata.language == l).cloned(),
168            None => resources.first().cloned(),
169        }
170    };
171
172    match output_format {
173        FormatType::AndroidStrings(lang) => {
174            let resource = pick_resource(lang);
175            if let Some(res) = resource {
176                AndroidStringsFormat::from(res).write_to(output)
177            } else {
178                Err(Error::InvalidResource(
179                    "No matching resource for output language.".to_string(),
180                ))
181            }
182        }
183        FormatType::Strings(lang) => {
184            let resource = pick_resource(lang);
185            if let Some(res) = resource {
186                StringsFormat::try_from(res)?.write_to(output)
187            } else {
188                Err(Error::InvalidResource(
189                    "No matching resource for output language.".to_string(),
190                ))
191            }
192        }
193        FormatType::Xcstrings => XcstringsFormat::try_from(resources)?.write_to(output),
194        FormatType::CSV => CSVFormat::try_from(resources)?.write_to(output),
195        FormatType::TSV => TSVFormat::try_from(resources)?.write_to(output),
196    }
197}
198
199/// Convert like [`convert`], with an option to normalize placeholders before writing.
200///
201/// When `normalize` is true, common iOS placeholder tokens like `%@`, `%1$@`, `%ld` are
202/// converted to canonical forms (`%s`, `%1$s`, `%d`) prior to serialization.
203/// Convert with optional placeholder normalization.
204///
205/// Example
206/// ```rust,no_run
207/// use langcodec::formats::FormatType;
208/// use langcodec::converter::convert_with_normalization;
209/// convert_with_normalization(
210///     "en.lproj/Localizable.strings",
211///     FormatType::Strings(Some("en".to_string())),
212///     "values/strings.xml",
213///     FormatType::AndroidStrings(Some("en".to_string())),
214///     true, // normalize placeholders (e.g., %@ -> %s)
215/// )?;
216/// # Ok::<(), langcodec::Error>(())
217/// ```
218pub fn convert_with_normalization<P: AsRef<Path>>(
219    input: P,
220    input_format: FormatType,
221    output: P,
222    output_format: FormatType,
223    normalize: bool,
224) -> Result<(), Error> {
225    let input = input.as_ref();
226    let output = output.as_ref();
227
228    // Carry language between single-language formats
229    let output_format = if let Some(lang) = input_format.language() {
230        output_format.with_language(Some(lang.clone()))
231    } else {
232        output_format
233    };
234
235    if !input_format.matches_language_of(&output_format) {
236        return Err(Error::InvalidResource(
237            "Input and output formats must match in language.".to_string(),
238        ));
239    }
240
241    // Read input as resources
242    let mut resources = match input_format {
243        FormatType::AndroidStrings(_) => vec![AndroidStringsFormat::read_from(input)?.into()],
244        FormatType::Strings(_) => vec![StringsFormat::read_from(input)?.into()],
245        FormatType::Xcstrings => Vec::<Resource>::try_from(XcstringsFormat::read_from(input)?)?,
246        FormatType::CSV => Vec::<Resource>::try_from(CSVFormat::read_from(input)?)?,
247        FormatType::TSV => Vec::<Resource>::try_from(TSVFormat::read_from(input)?)?,
248    };
249
250    // Ensure language is set for single-language inputs if provided on input_format
251    if let Some(l) = input_format.language().cloned() {
252        for res in &mut resources {
253            if res.metadata.language.is_empty() {
254                res.metadata.language = l.clone();
255            }
256        }
257    }
258
259    if normalize {
260        for res in &mut resources {
261            for entry in &mut res.entries {
262                match &mut entry.value {
263                    crate::types::Translation::Singular(v) => {
264                        *v = normalize_placeholders(v);
265                    }
266                    crate::types::Translation::Plural(p) => {
267                        for (_c, v) in p.forms.iter_mut() {
268                            *v = normalize_placeholders(v);
269                        }
270                    }
271                }
272            }
273        }
274    }
275
276    // Helper to extract resource by language if present, or first one
277    let pick_resource = |lang: Option<String>| -> Option<Resource> {
278        match lang {
279            Some(l) => resources.iter().find(|r| r.metadata.language == l).cloned(),
280            None => resources.first().cloned(),
281        }
282    };
283
284    match output_format {
285        FormatType::AndroidStrings(lang) => {
286            let resource = pick_resource(lang);
287            if let Some(res) = resource {
288                AndroidStringsFormat::from(res).write_to(output)
289            } else {
290                Err(Error::InvalidResource(
291                    "No matching resource for output language.".to_string(),
292                ))
293            }
294        }
295        FormatType::Strings(lang) => {
296            let resource = pick_resource(lang);
297            if let Some(res) = resource {
298                StringsFormat::try_from(res)?.write_to(output)
299            } else {
300                Err(Error::InvalidResource(
301                    "No matching resource for output language.".to_string(),
302                ))
303            }
304        }
305        FormatType::Xcstrings => XcstringsFormat::try_from(resources)?.write_to(output),
306        FormatType::CSV => CSVFormat::try_from(resources)?.write_to(output),
307        FormatType::TSV => TSVFormat::try_from(resources)?.write_to(output),
308    }
309}
310
311/// Convert a localization file from one format to another, inferring formats from file extensions.
312///
313/// This function attempts to infer the input and output formats from their file extensions.
314/// Returns an error if either format cannot be inferred.
315///
316/// # Arguments
317///
318/// * `input` - The input file path.
319/// * `output` - The output file path.
320///
321/// # Errors
322///
323/// Returns an `Error` if the format cannot be inferred, or if conversion fails.
324///
325/// # Example
326///
327/// ```rust,no_run
328/// use langcodec::converter::convert_auto;
329/// convert_auto("Localizable.strings", "strings.xml")?;
330/// # Ok::<(), langcodec::Error>(())
331/// ```
332pub fn convert_auto<P: AsRef<Path>>(input: P, output: P) -> Result<(), Error> {
333    let input_format = infer_format_from_path(&input).ok_or_else(|| {
334        Error::UnknownFormat(format!(
335            "Cannot infer input format from extension: {:?}",
336            input.as_ref().extension()
337        ))
338    })?;
339    let output_format = infer_format_from_path(&output).ok_or_else(|| {
340        Error::UnknownFormat(format!(
341            "Cannot infer output format from extension: {:?}",
342            output.as_ref().extension()
343        ))
344    })?;
345    convert(input, input_format, output, output_format)
346}
347
348#[cfg(test)]
349mod normalize_tests {
350    use super::*;
351    use std::fs;
352
353    #[test]
354    fn test_convert_strings_to_android_with_normalization() {
355        let tmp = tempfile::tempdir().unwrap();
356        let strings = tmp.path().join("en.strings");
357        let xml = tmp.path().join("strings.xml");
358
359        fs::write(&strings, "\n\"g\" = \"Hello %@ and %1$@ and %ld\";\n").unwrap();
360
361        // Without normalization: convert should succeed
362        convert(
363            &strings,
364            FormatType::Strings(Some("en".into())),
365            &xml,
366            FormatType::AndroidStrings(Some("en".into())),
367        )
368        .unwrap();
369        let content = fs::read_to_string(&xml).unwrap();
370        assert!(content.contains("Hello %"));
371
372        // With normalization
373        convert_with_normalization(
374            &strings,
375            FormatType::Strings(Some("en".into())),
376            &xml,
377            FormatType::AndroidStrings(Some("en".into())),
378            true,
379        )
380        .unwrap();
381        let content = fs::read_to_string(&xml).unwrap();
382        assert!(content.contains("%s"));
383        assert!(content.contains("%1$s"));
384        assert!(content.contains("%d"));
385    }
386}
387
388/// Auto-infer formats from paths and convert, with optional placeholder normalization.
389/// Auto-infer formats and convert with optional placeholder normalization.
390///
391/// Example
392/// ```rust,no_run
393/// use langcodec::converter::convert_auto_with_normalization;
394/// convert_auto_with_normalization(
395///     "Localizable.strings",
396///     "strings.xml",
397///     true, // normalize placeholders
398/// )?;
399/// # Ok::<(), langcodec::Error>(())
400/// ```
401pub fn convert_auto_with_normalization<P: AsRef<Path>>(
402    input: P,
403    output: P,
404    normalize: bool,
405) -> Result<(), Error> {
406    let input_format = infer_format_from_path(&input).ok_or_else(|| {
407        Error::UnknownFormat(format!(
408            "Cannot infer input format from extension: {:?}",
409            input.as_ref().extension()
410        ))
411    })?;
412    let output_format = infer_format_from_path(&output).ok_or_else(|| {
413        Error::UnknownFormat(format!(
414            "Cannot infer output format from extension: {:?}",
415            output.as_ref().extension()
416        ))
417    })?;
418    convert_with_normalization(input, input_format, output, output_format, normalize)
419}
420
421/// Infers a [`FormatType`] from a file path's extension.
422///
423/// Returns `Some(FormatType)` if the extension matches a known format, otherwise `None`.
424///
425/// # Example
426/// ```rust
427/// use langcodec::formats::FormatType;
428/// use langcodec::converter::infer_format_from_extension;
429///
430/// assert_eq!(
431///     infer_format_from_extension("Localizable.strings"),
432///     Some(FormatType::Strings(None))
433/// );
434/// assert_eq!(
435///     infer_format_from_extension("strings.xml"),
436///     Some(FormatType::AndroidStrings(None))
437/// );
438/// assert_eq!(
439///     infer_format_from_extension("Localizable.xcstrings"),
440///     Some(FormatType::Xcstrings)
441/// );
442/// assert_eq!(
443///     infer_format_from_extension("translations.csv"),
444///     Some(FormatType::CSV)
445/// );
446/// assert_eq!(
447///     infer_format_from_extension("data.tsv"),
448///     Some(FormatType::TSV)
449/// );
450/// assert_eq!(
451///     infer_format_from_extension("unknown.xyz"),
452///     None
453/// );
454/// ```
455pub fn infer_format_from_extension<P: AsRef<Path>>(path: P) -> Option<FormatType> {
456    let path = path.as_ref();
457    let extension = path.extension()?.to_str()?;
458
459    match extension.to_lowercase().as_str() {
460        "strings" => Some(FormatType::Strings(None)),
461        "xml" => Some(FormatType::AndroidStrings(None)),
462        "xcstrings" => Some(FormatType::Xcstrings),
463        "csv" => Some(FormatType::CSV),
464        "tsv" => Some(FormatType::TSV),
465        _ => None,
466    }
467}
468
469/// Infers a [`FormatType`] from a file path, including language detection.
470///
471/// This function combines extension-based format detection with language inference
472/// from the path structure (e.g., `values-es/strings.xml` → Spanish Android strings).
473///
474/// # Example
475/// ```rust
476/// use langcodec::formats::FormatType;
477/// use langcodec::converter::infer_format_from_path;
478///
479/// assert_eq!(
480///     infer_format_from_path("en.lproj/Localizable.strings"),
481///     Some(FormatType::Strings(Some("en".to_string())))
482/// );
483/// assert_eq!(
484///     infer_format_from_path("zh-Hans.lproj/Localizable.strings"),
485///     Some(FormatType::Strings(Some("zh-Hans".to_string())))
486/// );
487/// assert_eq!(
488///     infer_format_from_path("values-es/strings.xml"),
489///     Some(FormatType::AndroidStrings(Some("es".to_string())))
490/// );
491/// assert_eq!(
492///     infer_format_from_path("values/strings.xml"),
493///     Some(FormatType::AndroidStrings(Some("en".to_string())))
494/// );
495/// assert_eq!(
496///     infer_format_from_path("Localizable.xcstrings"),
497///     Some(FormatType::Xcstrings)
498/// );
499/// ```
500pub fn infer_format_from_path<P: AsRef<Path>>(path: P) -> Option<FormatType> {
501    match infer_format_from_extension(&path) {
502        Some(format) => match format {
503            // Multi-language formats, no language inference needed
504            FormatType::Xcstrings | FormatType::CSV | FormatType::TSV => Some(format),
505            FormatType::AndroidStrings(_) | FormatType::Strings(_) => {
506                let lang = infer_language_from_path(&path, &format).ok().flatten();
507                Some(format.with_language(lang))
508            }
509        },
510        None => None,
511    }
512}
513
514/// Infers the language code from a file path based on its format and structure.
515///
516/// This function analyzes the path to extract language information based on common
517/// localization file naming conventions.
518///
519/// # Arguments
520///
521/// * `path` - The file path to analyze
522/// * `format` - The format type to help with language inference
523///
524/// # Returns
525///
526/// `Ok(Some(language_code))` if a language can be inferred, `Ok(None)` if no language
527/// can be determined, or `Err` if there's an error in the inference process.
528///
529/// # Example
530///
531/// ```rust
532/// use langcodec::{converter::infer_language_from_path, formats::FormatType};
533/// use std::path::Path;
534///
535/// // Apple .strings files
536/// assert_eq!(
537///     infer_language_from_path("en.lproj/Localizable.strings", &FormatType::Strings(None)).unwrap(),
538///     Some("en".to_string())
539/// );
540/// assert_eq!(
541///     infer_language_from_path("fr.lproj/Localizable.strings", &FormatType::Strings(None)).unwrap(),
542///     Some("fr".to_string())
543/// );
544/// assert_eq!(
545///     infer_language_from_path("zh-Hans.lproj/Localizable.strings", &FormatType::Strings(None)).unwrap(),
546///     Some("zh-Hans".to_string())
547/// );
548///
549/// // Android strings.xml files
550/// assert_eq!(
551///     infer_language_from_path("values-es/strings.xml", &FormatType::AndroidStrings(None)).unwrap(),
552///     Some("es".to_string())
553/// );
554/// assert_eq!(
555///     infer_language_from_path("values-fr/strings.xml", &FormatType::AndroidStrings(None)).unwrap(),
556///     Some("fr".to_string())
557/// );
558///
559/// // No language in path
560/// assert_eq!(
561///     infer_language_from_path("values/strings.xml", &FormatType::AndroidStrings(None)).unwrap(),
562///     Some("en".to_string())
563/// );
564/// ```
565pub fn infer_language_from_path<P: AsRef<Path>>(
566    path: P,
567    format: &FormatType,
568) -> Result<Option<String>, Error> {
569    use std::str::FromStr;
570    use unic_langid::LanguageIdentifier;
571
572    let path = path.as_ref();
573
574    // Helper: validate and normalize a language candidate (accepts underscores, normalizes to hyphens)
575    fn normalize_lang(candidate: &str) -> Option<String> {
576        let canonical = candidate.replace('_', "-");
577        LanguageIdentifier::from_str(&canonical).ok()?;
578        Some(canonical)
579    }
580
581    // Helper: parse Android values- qualifiers into BCP-47 if possible
582    fn parse_android_values_lang(values_component: &str) -> Option<String> {
583        // values-zh-rCN → zh-CN; values-es → es; values-b+zh+Hans+CN → zh-Hans-CN
584        if let Some(rest) = values_component.strip_prefix("values-") {
585            if rest.is_empty() {
586                return None;
587            }
588            if let Some(b_rest) = rest.strip_prefix("b+") {
589                // BCP-47 style encoded in plus-separated tags
590                let parts: Vec<&str> = b_rest.split('+').collect();
591                if parts.is_empty() {
592                    return None;
593                }
594                let lang = parts.join("-");
595                return normalize_lang(&lang);
596            }
597            // Legacy qualifiers: lang[-rREGION][-SCRIPT]...
598            let mut lang: Option<String> = None;
599            let mut region: Option<String> = None;
600            for token in rest.split('-') {
601                if token.is_empty() {
602                    continue;
603                }
604                if let Some(r) = token.strip_prefix('r') {
605                    if !r.is_empty() {
606                        region = Some(r.to_string());
607                    }
608                } else if lang.is_none() {
609                    lang = Some(token.to_string());
610                }
611            }
612            if let Some(l) = lang {
613                let mut tag = l;
614                if let Some(r) = region {
615                    tag = format!("{}-{}", tag, r);
616                }
617                return normalize_lang(&tag);
618            }
619        }
620        None
621    }
622
623    // Iterate from the filename upward until a language is found
624    let mut components: Vec<String> = path
625        .components()
626        .map(|c| c.as_os_str().to_string_lossy().into_owned())
627        .collect();
628    components.reverse();
629
630    for comp in components {
631        match format {
632            FormatType::Strings(_) => {
633                // Apple: directory like zh-Hans.lproj, or filename like en.strings
634                if let Some(lang_dir) = comp.strip_suffix(".lproj")
635                    && let Some(lang) = normalize_lang(lang_dir)
636                {
637                    return Ok(Some(lang));
638                }
639                if comp.ends_with(".strings")
640                    && let Some(stem) = Path::new(&comp).file_stem().and_then(|s| s.to_str())
641                {
642                    let looks_like_lang = (stem.len() == 2
643                        && stem.chars().all(|c| c.is_ascii_lowercase()))
644                        || stem.contains('-');
645                    if looks_like_lang && let Some(lang) = normalize_lang(stem) {
646                        return Ok(Some(lang));
647                    }
648                }
649            }
650            FormatType::AndroidStrings(_) => {
651                // Android: values (default → en), values-xx, values-xx-rYY, values-b+zh+Hans+CN, etc.
652                if comp == "values" {
653                    // Treat base `values/` as English by default
654                    return Ok(Some("en".to_string()));
655                }
656                if let Some(lang) = parse_android_values_lang(&comp) {
657                    return Ok(Some(lang));
658                }
659            }
660            _ => {}
661        }
662    }
663
664    Ok(None)
665}
666
667/// Writes resources to a file based on their stored format metadata.
668///
669/// This function determines the output format from the resource metadata and writes
670/// the resources accordingly. Supports formats with single or multiple resources per file.
671///
672/// # Parameters
673/// - `resources`: Slice of resources to write.
674/// - `file_path`: Destination file path.
675///
676/// # Returns
677///
678/// `Ok(())` if writing succeeds, or an `Error` if the format is unsupported or writing fails.
679pub fn write_resources_to_file(resources: &[Resource], file_path: &String) -> Result<(), Error> {
680    let path = Path::new(&file_path);
681
682    if let Some(first) = resources.first() {
683        match first.metadata.custom.get("format").map(String::as_str) {
684            Some("AndroidStrings") => AndroidStringsFormat::from(first.clone()).write_to(path)?,
685            Some("Strings") => StringsFormat::try_from(first.clone())?.write_to(path)?,
686            Some("Xcstrings") => XcstringsFormat::try_from(resources.to_vec())?.write_to(path)?,
687            Some("CSV") => CSVFormat::try_from(resources.to_vec())?.write_to(path)?,
688            Some("TSV") => TSVFormat::try_from(resources.to_vec())?.write_to(path)?,
689            _ => Err(Error::UnsupportedFormat(format!(
690                "Unsupported format: {:?}",
691                first.metadata.custom.get("format")
692            )))?,
693        }
694    }
695
696    Ok(())
697}
698
699/// Merges multiple resources into a single resource with conflict resolution.
700///
701/// This function merges resources that all have the same language.
702/// Only entries with the same ID are treated as conflicts.
703///
704/// # Arguments
705///
706/// * `resources` - The resources to merge (must all have the same language)
707/// * `conflict_strategy` - How to handle conflicting entries (same ID)
708///
709/// # Returns
710///
711/// A merged resource with all entries from the input resources.
712///
713/// # Errors
714///
715/// Returns an error if:
716/// - No resources are provided
717/// - Resources have different languages (each Resource represents one language)
718///
719/// # Example
720///
721/// ```rust
722/// use langcodec::{converter::merge_resources, types::{Resource, Metadata, Entry, Translation, EntryStatus, ConflictStrategy}};
723///
724/// // Create some sample resources for merging
725/// let resource1 = Resource {
726///     metadata: Metadata {
727///         language: "en".to_string(),
728///         domain: "domain".to_string(),
729///         custom: std::collections::HashMap::new(),
730///     },
731///     entries: vec![
732///         Entry {
733///             id: "hello".to_string(),
734///             value: Translation::Singular("Hello".to_string()),
735///             comment: None,
736///             status: EntryStatus::Translated,
737///             custom: std::collections::HashMap::new(),
738///         }
739///     ],
740/// };
741///
742/// let merged = merge_resources(
743///     &[resource1],
744///     &ConflictStrategy::Last
745/// )?;
746/// # Ok::<(), langcodec::Error>(())
747/// ```
748pub fn merge_resources(
749    resources: &[Resource],
750    conflict_strategy: &ConflictStrategy,
751) -> Result<Resource, Error> {
752    if resources.is_empty() {
753        return Err(Error::InvalidResource("No resources to merge".to_string()));
754    }
755
756    // Validate that all resources have the same language
757    let first_language = &resources[0].metadata.language;
758    for (i, resource) in resources.iter().enumerate() {
759        if resource.metadata.language != *first_language {
760            return Err(Error::InvalidResource(format!(
761                "Cannot merge resources with different languages: resource {} has language '{}', but first resource has language '{}'",
762                i + 1,
763                resource.metadata.language,
764                first_language
765            )));
766        }
767    }
768
769    let mut merged = resources[0].clone();
770    let mut all_entries = std::collections::HashMap::new();
771
772    // Collect all entries from all resources
773    for resource in resources {
774        for entry in &resource.entries {
775            // Use the original entry ID for conflict resolution
776            // Since all resources have the same language, conflicts are based on ID only
777            match conflict_strategy {
778                crate::types::ConflictStrategy::First => {
779                    all_entries
780                        .entry(&entry.id)
781                        .or_insert_with(|| entry.clone());
782                }
783                crate::types::ConflictStrategy::Last => {
784                    all_entries.insert(&entry.id, entry.clone());
785                }
786                crate::types::ConflictStrategy::Skip => {
787                    if all_entries.contains_key(&entry.id) {
788                        // Remove the existing entry and skip this one too
789                        all_entries.remove(&entry.id);
790                        continue;
791                    }
792                    all_entries.insert(&entry.id, entry.clone());
793                }
794            }
795        }
796    }
797
798    // Convert back to vector and sort by key for consistent output
799    merged.entries = all_entries.into_values().collect();
800    merged.entries.sort_by(|a, b| a.id.cmp(&b.id));
801
802    Ok(merged)
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::types::{Entry, EntryStatus, Metadata, Plural, PluralCategory, Translation};
809    use std::collections::{BTreeMap, HashMap};
810
811    #[test]
812    fn test_convert_csv_to_android_strings_en() {
813        let tmp = tempfile::tempdir().unwrap();
814        let input = tmp.path().join("in.csv");
815        let output = tmp.path().join("strings.xml");
816
817        // CSV with header and two rows
818        std::fs::write(
819            &input,
820            "key,en,fr\nhello,Hello,Bonjour\nbye,Goodbye,Au revoir\n",
821        )
822        .unwrap();
823
824        convert(
825            &input,
826            FormatType::CSV,
827            &output,
828            FormatType::AndroidStrings(Some("en".into())),
829        )
830        .unwrap();
831
832        // Read back as Android to verify
833        let android = crate::formats::AndroidStringsFormat::read_from(&output).unwrap();
834        // ensure we have only strings for the selected language
835        assert_eq!(android.strings.len(), 2);
836        let mut names: Vec<&str> = android.strings.iter().map(|s| s.name.as_str()).collect();
837        names.sort();
838        assert_eq!(names, vec!["bye", "hello"]);
839        let hello = android.strings.iter().find(|s| s.name == "hello").unwrap();
840        assert_eq!(hello.value, "Hello");
841        let bye = android.strings.iter().find(|s| s.name == "bye").unwrap();
842        assert_eq!(bye.value, "Goodbye");
843    }
844
845    #[test]
846    fn test_convert_xcstrings_plurals_to_android() {
847        let tmp = tempfile::tempdir().unwrap();
848        let input = tmp.path().join("in.xcstrings");
849        let output = tmp.path().join("strings.xml");
850
851        // Build a Resource with a plural entry for English
852        let mut custom = HashMap::new();
853        custom.insert("source_language".into(), "en".into());
854        custom.insert("version".into(), "1.0".into());
855
856        let mut forms = BTreeMap::new();
857        forms.insert(PluralCategory::One, "One apple".to_string());
858        forms.insert(PluralCategory::Other, "%d apples".to_string());
859
860        let res = Resource {
861            metadata: Metadata {
862                language: "en".into(),
863                domain: "domain".into(),
864                custom,
865            },
866            entries: vec![Entry {
867                id: "apples".into(),
868                value: Translation::Plural(Plural {
869                    id: "apples".into(),
870                    forms,
871                }),
872                comment: Some("Count apples".into()),
873                status: EntryStatus::Translated,
874                custom: HashMap::new(),
875            }],
876        };
877
878        // Write XCStrings input
879        let xc = crate::formats::XcstringsFormat::try_from(vec![res]).unwrap();
880        xc.write_to(&input).unwrap();
881
882        // Convert to Android (English)
883        convert(
884            &input,
885            FormatType::Xcstrings,
886            &output,
887            FormatType::AndroidStrings(Some("en".into())),
888        )
889        .unwrap();
890
891        // Read back as Android
892        let android = crate::formats::AndroidStringsFormat::read_from(&output).unwrap();
893        assert_eq!(android.plurals.len(), 1);
894        let p = android.plurals.into_iter().next().unwrap();
895        assert_eq!(p.name, "apples");
896        // Should include at least 'one' and 'other'
897        let mut qs: Vec<_> = p
898            .items
899            .into_iter()
900            .map(|i| match i.quantity {
901                PluralCategory::One => ("one", i.value),
902                PluralCategory::Other => ("other", i.value),
903                PluralCategory::Zero => ("zero", i.value),
904                PluralCategory::Two => ("two", i.value),
905                PluralCategory::Few => ("few", i.value),
906                PluralCategory::Many => ("many", i.value),
907            })
908            .collect();
909        qs.sort_by(|a, b| a.0.cmp(b.0));
910        assert!(qs.iter().any(|(q, v)| *q == "one" && v == "One apple"));
911        assert!(qs.iter().any(|(q, v)| *q == "other" && v == "%d apples"));
912    }
913}